Server state and client state in React
Back to blog

Server state and client state: why React apps need two libraries

Almost every React Native app holds two kinds of state that behave nothing alike: server state and client state. Tell them apart and the choice of library mostly answers itself; blur them and you end up building by hand what a library would give you for free.

These two posts are a short break from the Module Federation series, because the federated work ahead leans on exactly this distinction. It is easier to get straight in one plain app first.

Two pairings hold up well as an app grows. With Redux Toolkit and RTK Query you keep one store, but server and client state are handled by two different tools inside it: RTK Query is a purpose-built server-state layer, caching machinery and all, and client state stays in plain createSlice slices. With TanStack Query and Zustand you split across two libraries instead, the server cache living in TanStack’s own client, outside any store, and client state in a small Zustand store. Either way the two kinds get different handling and are never forced through one. The shortcut that undoes that is one general-purpose store, a thunk per request, and cache logic grown by hand. It is fine on a small app and turns into a second job as it grows.

Two kinds of state

Server state is a local copy of something a server owns: a profile from /users/me, a product list, an order status. The copy is only ever a snapshot, and the original can change the instant after you read it. That one fact is where all the work comes from. A copy of remote data needs caching, so you are not refetching on every mount; deduplication, so two components asking for the same resource fire one request; background refetching, so the screen freshens quietly; retry, for the network that drops; invalidation, so a write can mark related data stale; and garbage collection, to drop what nothing is watching. None of that is peculiar to React. It is what any client owes data it does not own.

Client state is the other half: data the app owns outright. The selected account, a sort order, a form draft, whether a modal is open, the auth tokens you hold after login (where you keep them safely is its own topic), the user’s theme and language. It has to be held, it has to be reactive, and once in a while it has to survive a restart. That is nearly the whole list. It cannot go stale, so it needs no caching; there is nothing to refetch, so it needs no background sync. The two are not degrees of the same thing. They have different jobs, and a library built for one is the wrong tool for the other.

What each half asks for

Server state needs a library that already has the machinery. Build it by hand and every feature grows the same scaffolding: a request, a loading flag, an error field, and a branch for each of the three states a fetch can be in. A server-state library hands you all of it behind a hook. On Redux, that library is RTK Query:

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: builder => ({
    getProfile: builder.query<Profile, void>({
      query: () => '/users/me',
    }),
  }),
});

export const { useGetProfileQuery } = api;

The component reads one line, and caching, deduplication, retry and refetch-on-focus come with it:

const { data: profile, isLoading, error } = useGetProfileQuery();

Off Redux, TanStack Query does the same job in the same shape: declare the query, let the library run the cache and the lifecycle.

export const useProfile = () =>
  useQuery({ queryKey: ['profile'], queryFn: getProfile });

Client state needs almost nothing by comparison. A preference has no server to compare against, so whatever the app holds is correct by definition, and caching or refetching would be solving a problem it does not have. A small store is enough. With Zustand:

export const useSettings = create<Settings>()(set => ({
  theme: 'system',
  setTheme: theme => set({ theme }),
}));

No reducer, no dispatch, no lifecycle. On Redux the same thing is a plain createSlice with a couple of setters. Either way it holds a value and lets components react to it. The mistake in both directions is the same: put client state in a server-state tool and you carry machinery it never uses; make one hand-rolled store do both and you rebuild the library’s job by hand.

Which split, and when

There is no universal winner between the two pairings, and I would distrust any post that handed you one. If your team already lives in Redux, RTK Query is the shorter road, because it drops the server-state machinery into the store you already run. Coming to a fresh app with no Redux commitment, TanStack Query and Zustand are two focused tools that compose without treading on each other. And on a small app, one store with a thunk per request is a perfectly reasonable place to start. Watch for the day you find yourself writing cache logic by hand: that is the signal to reach for a tool that already ships it.

So before you next reach for a slice to hold an API response, stop on the smaller question first: do you need server-state machinery, or just somewhere to keep a value a few components can watch?

The question only gets sharper once features stop sharing a codebase. When each ships as its own app and loads at runtime, whose cache is authoritative stops being a convenience and becomes something you have to coordinate. That is where the Module Federation series picks back up, and it is far easier to reason about once the plain-app version is second nature.

Before then, the next post stays on the server half and looks at a single difference between these tools: how RTK Query’s cache tags and TanStack’s query keys handle the same invalidation, and why that small API difference decides how well each holds up once more than one team is committing to it.

Warren de Leon
Warren de Leon

Software Engineering Manager. Most recently led the Mobile Platform team at Hargreaves Lansdown. Writing about engineering leadership, React Native, and building great teams.

View profile