Migrating a Redux store to a server cache
"Half our Redux store is server data. What's the migration and why?"
What it is
Most large Redux stores are two different things wearing one coat.
Server state is data that lives on a server, is owned by the server, can be changed by other users or processes, and is therefore always potentially stale in the client. Lists of orders, a user profile, search results.
Client state is data that exists only in the browser and has no authority anywhere else. Which modal is open, the contents of an unsubmitted form, the current filter selection, whether the sidebar is collapsed.
The migration is: move server state to a server cache library (TanStack Query, SWR, RTK Query), keep client state in the store, and delete everything that existed only to manage the difference.
The lead-level framing to open with: most state-management problems are caused by treating server data and UI state as the same thing. They have different lifecycles, different invalidation rules and different owners, and a single global store forces them into one model that fits neither.
The problem it solves
A Redux store holding server data has to hand-roll the entire cache lifecycle:
- Loading and error states, per resource, as explicit reducer branches.
- Staleness, which usually means no policy at all, so data is fetched on mount and never again.
- Deduplication, so three components mounting simultaneously do not fire three identical requests.
- Invalidation after a mutation, which is where the bugs live: someone adds an endpoint and forgets to dispatch the refetch, so a screen shows stale data until a reload.
- Refetch on focus or reconnect, which almost nobody implements, so a tab left open overnight shows yesterday's data.
- Garbage collection, which nobody implements, so the store grows for the session's lifetime.
Every one of those is a solved problem in a cache library. The reason this is worth an interview answer is the size of the deletion: in most codebases doing this, somewhere between a third and two-thirds of the store's code exists to reimplement caching, badly.
Mechanics
Before
// Three action types, a reducer branch each, a thunk, and a selector.
// Multiply by every resource in the application.
const FETCH_ORDERS_REQUEST = 'orders/fetchRequest';
const FETCH_ORDERS_SUCCESS = 'orders/fetchSuccess';
const FETCH_ORDERS_FAILURE = 'orders/fetchFailure';
function ordersReducer(state = { items: [], loading: false, error: null }, action) {
switch (action.type) {
case FETCH_ORDERS_REQUEST: return { ...state, loading: true, error: null };
case FETCH_ORDERS_SUCCESS: return { items: action.payload, loading: false, error: null };
case FETCH_ORDERS_FAILURE: return { ...state, loading: false, error: action.error };
default: return state;
}
}
export const fetchOrders = (customerId) => async (dispatch) => {
dispatch({ type: FETCH_ORDERS_REQUEST });
try {
const res = await api.get(`/orders?customer=${customerId}`);
dispatch({ type: FETCH_ORDERS_SUCCESS, payload: res.data });
} catch (e) {
dispatch({ type: FETCH_ORDERS_FAILURE, error: e.message });
}
};
// In the component:
useEffect(() => { dispatch(fetchOrders(customerId)); }, [customerId, dispatch]);
const { items, loading, error } = useSelector(s => s.orders);
Roughly 30 lines per resource, and none of it handles staleness, deduplication, refetch on focus, retry, or garbage collection.
After
// The whole thing. Loading, error, caching, dedup, refetch-on-focus,
// retry with backoff, and GC of unused entries are all included.
function useOrders(customerId) {
return useQuery({
queryKey: ['orders', customerId], // the cache key IS the dependency array
queryFn: () => api.get(`/orders?customer=${customerId}`).then(r => r.data),
staleTime: 30_000, // treat as fresh for 30s: no refetch on remount
gcTime: 5 * 60_000, // evict 5 minutes after the last observer unmounts
});
}
// In the component:
const { data, isPending, error } = useOrders(customerId);
The mutation half, which is where the invalidation bugs used to be:
const queryClient = useQueryClient();
const cancelOrder = useMutation({
mutationFn: (id) => api.post(`/orders/${id}/cancel`),
// Optimistic update: show the change immediately, roll back on failure.
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['orders'] }); // stop in-flight refetches
const previous = queryClient.getQueryData(['orders', customerId]);
queryClient.setQueryData(['orders', customerId], (old) =>
old.map(o => o.id === id ? { ...o, status: 'cancelling' } : o));
return { previous }; // rollback context
},
onError: (_err, _id, ctx) => {
queryClient.setQueryData(['orders', customerId], ctx.previous);
},
// Invalidation is declarative and colocated with the mutation, which is
// why it stops being forgotten.
onSettled: () => queryClient.invalidateQueries({ queryKey: ['orders'] }),
});
The important structural change is the last line. In the Redux version, knowing which slices to refresh after a mutation is tribal knowledge spread across thunks. Here it sits next to the mutation, so adding an endpoint and forgetting to invalidate is a visible omission rather than an invisible one.
The classification test
For each slice, three questions. If any answer is yes, it is server state:
- Does this originate from an API?
- Can it change without this browser doing anything?
- Would a page reload get the current value from the server?
If all three are no, it is client state and it stays.
Typical result on a real store: 60 to 70 percent of slices are server state, 20 percent are genuine client state, and 10 percent are a mess of both in the same slice, which is the interesting category.
The awkward middle
Three cases the clean split does not cover, and being able to name them is what makes the answer credible rather than a sales pitch:
Normalised entities shared across screens. A store using entity adapters so an
order edited on one screen updates everywhere gets that consistency for free. A
query cache keyed by request does not: ['orders', customerId] and
['order', orderId] are separate entries holding the same order. The answer is to
invalidate both, or use the query client to write through to related keys, and it
is genuinely more manual than a normalised store. For most applications the extra
network round trip is cheaper than the normalisation machinery; for a
document-editing product it may not be.
Derived state across server and client. "Show orders matching the current
filter" combines server data with client state. This composes fine (query returns
data, useMemo filters it against the store value) but people find it disorienting
because it spans two systems.
Real-time updates. Data arriving over a WebSocket has to be written into the
cache rather than dispatched. queryClient.setQueryData handles it, and if the
application is primarily push-driven rather than fetch-driven the cache library is
less of a fit.
The migration order
Do not attempt a big-bang rewrite. The libraries coexist deliberately.
- Install alongside Redux. Change nothing else. Both providers, both stores, no conflict.
- Migrate one leaf screen: one that reads server data and is not read by anything else. Ship it. Confirm nothing broke and that the team likes it.
- Migrate by resource, not by screen. Move all consumers of
ordersat once, then delete the orders slice. Half-migrated resources are the worst state to be in, because now two systems hold the same data and can disagree. - Delete as you go. The slice, its actions, its thunks, its selectors, its tests. If the deletion is not happening, the migration is not happening, and the codebase has grown rather than shrunk.
- Stop when the remaining store is genuinely client state. That is usually a fraction of the original and often small enough that Zustand or Context replaces it entirely.
A worked example
An admin dashboard. Redux store: 24 slices, roughly 8,000 lines including tests. Reported problems: a stale-data bug class that recurs every few sprints, three duplicate requests on dashboard load, and new engineers taking two weeks to understand the data flow.
Classification: 16 slices are server state (orders, customers, products,
invoices, shipments, and so on). 5 are client state (modal visibility, table column
preferences, the filter panel, wizard step, toast queue). 3 are mixed, and the worst
being checkout, which holds both the server-fetched cart and the client-side
form draft in one object, which is exactly why cart bugs are hard to reason about.
Execution over six weeks, one engineer at roughly 40 percent time:
- Week 1: install, migrate the shipments screen (one consumer), ship.
- Weeks 2 to 4: migrate the 16 server slices by resource, deleting each as its last consumer moves.
- Week 5: split the 3 mixed slices. The server half becomes a query, the client
half stays.
checkoutsplits into a cart query and acheckoutDraftclient slice, and the cart bugs stop. - Week 6: the remaining 5 client slices move to Zustand, and Redux is removed.
Result: roughly 8,000 lines to roughly 1,800. Duplicate requests eliminated by deduplication on the shared query key. The stale-data bug class disappears because invalidation is declared next to the mutation rather than remembered. A measured bonus nobody predicted: dashboard load dropped from three requests to one, and navigating back to a recently-visited screen became instant because of the cache's stale-while-revalidate behaviour.
What did not improve, and say this: the normalised-entity consistency that entity adapters gave for free now requires explicitly invalidating related keys. Two bugs during the migration came from exactly that, both caught in review once the team knew to look. It is a real cost, and it is smaller than the one it replaced.
Production evidence
Redux's own maintainers recommend this. The official Redux documentation states that if you are using Redux primarily to cache server state, a purpose-built data-fetching library is likely a better fit, and Redux Toolkit ships RTK Query specifically to serve that use case within the Redux ecosystem. That is the strongest possible evidence, and it is the one to cite: this is not a framework-versus-framework argument, it is the framework's authors saying the tool was being used for the wrong job.
TanStack Query (formerly React Query) popularised the server-state framing; Tanner Linsley's articles arguing that server state is a fundamentally different problem from client state are the origin of the vocabulary used in this answer.
SWR (Vercel) implements the same model with stale-while-revalidate semantics borrowed directly from the HTTP cache directive, which is a nice illustration that the pattern is not new, it is HTTP caching applied at the component level.
Apollo Client solved this earlier for GraphQL with a normalised cache, which is worth naming because it is the counter-example: a normalised client cache does give you cross-screen entity consistency, at the cost of significant complexity in cache configuration.
The debate
The case for keeping server state in Redux is real in two situations. Normalised entity consistency: if the same entity appears on many screens and must update everywhere on edit, entity adapters give that for free and a query cache does not. A large existing investment: a working store, a team fluent in it, and no acute pain means the migration is churn with a developer-experience payoff, which is worth something and is not worth a quarter.
The case for migrating: the store is reimplementing a cache, and every hand-rolled cache is worse than a library one. Specifically it lacks staleness policy, deduplication, refetch on focus, retry, and garbage collection, and adding those is strictly more work than adopting a library that has them.
My position: split by ownership. Server state goes to a server cache library; client state stays in a store, and the store that remains is usually small enough that Redux is no longer the right tool for it either. Migrate incrementally, by resource rather than by screen, and delete as you go. If the deletion is not happening, you have added a library rather than migrated.
If the team is already invested in Redux and does not want a new dependency, RTK Query is the same answer inside the ecosystem and is a perfectly good outcome; the point is the server-versus-client split, not the specific package.
Migrating is the wrong move when there is no acute pain, when the application is primarily real-time and push-driven rather than fetch-driven, when cross-screen normalised consistency is a hard product requirement, or when the team is mid-way through a different large migration.
Follow-up Q&A
"Half our Redux store is server data. What's the migration and why?" Classify every slice: server state is anything that originates from an API, can change without this browser doing anything, or would be different after a reload. Typically that is 60 to 70 percent. Move it to TanStack Query or RTK Query, which gives you caching, deduplication, staleness policy, retry, refetch on focus and garbage collection for free, and makes invalidation declarative next to the mutation instead of remembered. Keep genuine client state in the store. Migrate one resource at a time, deleting the slice as its last consumer moves, and stop when what remains is genuinely client state.
"What do you lose?" Normalised entity consistency. Redux entity adapters mean one order lives in one place, so editing it updates every screen. A query cache is keyed by request, so the same order can exist in an orders-list entry and an order-detail entry, and you must invalidate both. It is real, it is more manual, and for most applications the extra round trip costs less than maintaining the normalisation. For a collaborative document editor it might not, and that is where Apollo's normalised cache or keeping Redux is defensible.
"How do you handle optimistic updates?" In the mutation's onMutate: cancel
in-flight queries for the affected key so a refetch cannot overwrite your optimistic
value, snapshot the current cache entry, write the optimistic value, and return the
snapshot as rollback context. On error, restore the snapshot. On settled, invalidate
so the server's version wins. It is the same shape as a Redux optimistic update and
the difference is that the rollback context is a first-class parameter rather than
something you thread through action payloads yourself.
"Isn't this just moving the problem?" No, and the reason is ownership. Redux forces you to model server data as if the client owned it: you write reducers that decide what the data becomes, which is a lie, because the server decides. A cache library models it as what it is, a local copy of remote data with a staleness policy, so the questions you have to answer become the right ones (how stale can this be, when do I invalidate) rather than the wrong ones (what does the reducer do on this action).
"What would you migrate first?" A leaf screen: one that reads server data and whose data no other screen depends on. It proves the pattern, it is reversible, and it gives the team something to review before committing. Then switch to migrating by resource rather than by screen, because a half-migrated resource means two systems hold the same data and can disagree, which is worse than either end state.
Common misconceptions
The most common is that this is a Redux-versus-TanStack argument. Redux's own documentation makes the same recommendation and Redux Toolkit ships RTK Query for this exact purpose. The argument is about server state versus client state, not about libraries.
The second is that a global store gives you consistency. It gives you a single copy, which is not the same thing: if nothing refetches, that single copy is consistently stale.
The third is that the migration is about deleting boilerplate. Boilerplate is the visible symptom. The real change is that staleness and invalidation become explicit policies with defaults, rather than behaviours that emerge from whichever thunks happen to have been dispatched.
Interview delivery note
Say this: "Most state problems come from treating server data and UI state as the same thing, and they aren't: different lifecycles, different invalidation rules, different owners. I'd classify every slice: anything that comes from an API, can change without this browser doing anything, or would be different after a reload is server state, and move that to TanStack Query or RTK Query. That deletes the hand-rolled loading and error branches and, more importantly, makes invalidation declarative next to the mutation instead of something you have to remember. What stays is genuine client state, and it's usually small enough that Redux isn't the right tool for it any more."
The depth signals: naming what you lose (normalised entity consistency across screens) before being asked, and citing that Redux's own documentation recommends this, which turns a preference into an appeal to the strongest possible authority. Close with the migration discipline: "by resource rather than by screen, deleting the slice as its last consumer moves, because a half-migrated resource means two systems holding the same data."
Further reading
- Redux documentation on when not to use Redux, and the RTK Query overview, which states the server-state case directly.
- TanStack Query documentation on
staleTimeversusgcTime, query keys, and the optimistic-update pattern. - Tanner Linsley's writing on server state as a distinct category from client state, which is the origin of the framing.
- SWR's documentation on stale-while-revalidate, and RFC 5861, for where the semantics come from.