Server state and client state: why React apps need two libraries
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
- 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.
Comments
No comments yet. Start the discussion.