Skip to content

Frontend architecture

Server state is not client state

A React app has two kinds of state, and putting both in a global store is how stale totals survive a successful order. TanStack Query as the boundary.

3 min readUpdated 2 Oct 20264 sections

In a React application backed by an API there are two different things called state, and they have opposite rules. Client state is authoritative in the browser: the contents of a cart, whether a menu is open, the Stripe publishable key. Server state is a copy of something that lives on the API, and it is always a copy, no matter how confident the UI looks.

The Local Chef Bazaar client uses TanStack Query for the second kind and React context for the first, and the reason is not preference. It is that the two have different lifecycles.

How this goes wrong

Mirror server data into a global store and it looks fine until a mutation succeeds. The order is created, the API returns 201, the query cache is invalidated and refetched, and the new value arrives in the query cache. The copy in the global store is a different object, held by a different subscriber, and nobody told it. The user sees the old total next to the confirmation.

Query keys are the design

The key is the address of a piece of server state, and getting the shape right does more work than any other single decision in a data layer. The rule that holds up: a key describes everything that changes the result, and nothing else.

features/orders/api.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

// The key factory is the important part: one place defines every address,
// and every hook derives its key from it.
export const orderKeys = {
  all: ["orders"] as const,
  list: (cookId?: string) => [...orderKeys.all, "list", { cookId }] as const,
  detail: (id: string) => [...orderKeys.all, "detail", id] as const,
};

export function useOrder(id: string) {
  return useQuery({
    queryKey: orderKeys.detail(id),
    queryFn: () => api.get(`/orders/${id}`).then((r) => r.data),
  });
}

export function useCancelOrder() {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => api.post(`/orders/${id}/cancel`),
    onSuccess: (_data, id) => {
      // Targeted invalidation: refetch this order, plus any list it appears in.
      qc.invalidateQueries({ queryKey: orderKeys.detail(id) });
      qc.invalidateQueries({ queryKey: orderKeys.list() });
    },
  });
}
Condensed from the pattern used in the food marketplace client.

A key factory is worth the extra file. The alternative is key strings written at each call site, and the first time someone writes "orders" instead of orderKeys.all the cache splits in two and a mutation invalidates half of it.

Mutations are the interesting part

A query answers "what is true now". A mutation changes what is true, and its callbacks are where the cache is repaired. Three callbacks cover almost everything.

  • onMutate

    Runs before the request. Cancel in-flight queries for the same key and snapshot the previous value, so the UI can show an optimistic update and roll back if the request fails.

  • onSuccess

    The mutation succeeded. Either write the returned value into the cache with setQueryData, which avoids a round trip entirely, or invalidate the affected keys.

  • onError

    Restore the snapshot taken in onMutate. This is the step people skip, and skipping it is how an optimistic update becomes permanent.

features/orders/usePlaceOrder.ts
export function usePlaceOrder() {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: (input: PlaceOrderInput) =>
      api.post("/orders", input).then((r) => r.data as Order),

    onSuccess: (order) => {
      // The response IS the new state. No refetch needed for this key.
      qc.setQueryData(orderKeys.detail(order.id), order);
      qc.invalidateQueries({ queryKey: orderKeys.list() });
    },

    onError: (error) => {
      toast.error(error.response?.data?.message ?? "Could not place the order");
    },
  });
}

Using setQueryData with the response rather than invalidating is the cheapest performance win in a data layer. The server has already told you the answer. Throwing it away and asking again is a wasted request on every single mutation.

Forms are not server state

An in-progress order form is client state, even though it will become server state. The client also uses react-hook-form, and the split is clean: the form library owns field values, validation messages and dirty state, and the moment the form is submitted the values leave for the mutation and the form is reset.