The state ladder, and choosing async machinery
What it is
The state ladder is a decision order: for any piece of state, start at the bottom rung and climb only when the rung you are on genuinely cannot hold it. Each rung up costs more coupling, more code, and a wider blast radius for changes.
6. URL / router shareable, bookmarkable, survives reload,
back button works. Filters, tabs, pagination,
the selected entity.
5. Global client store genuinely app-wide client state that many
(Redux/Zustand/Jotai) distant components read AND write.
4. Server cache anything whose source of truth is a server.
(TanStack Query/SWR/ NOT client state, and this is the rung most
RTK Query) teams skip.
3. Context low-frequency, config-shaped values: theme,
locale, the auth'd user, a feature flag set.
2. Lifted state state two or three sibling components share.
1. Local state useState/useReducer in the component that
(DEFAULT) owns it.
What this is confused with: "global state management." The phrase treats one rung as the whole ladder. The single largest cause of oversized Redux stores is putting rung-4 data (cached server responses) on rung 5, where you then hand-write loading flags, error flags, staleness, refetching, deduplication and cache invalidation that a server-cache library already implements.
Also confused: Context as a state manager. Context is a transport mechanism, not a store. It has no selector granularity, so every consumer re-renders when the value changes.
The problem it solves
Without an ordering rule, every piece of state ends up as high on the ladder as the first person to need it put it.
Sprint 1 a modal's open/closed flag lives in useState. Correct.
Sprint 4 a second component needs to open it -> moved to Redux,
because "that's where shared state goes"
Sprint 9 the store has 40 slices, 200 action types, and a
`ui.modals.exportDialog.isOpen` boolean that three files
reference and nobody dares delete
Sprint 14 a new engineer adds a field to a form. To do it they touch
the slice, the reducer, an action creator, a selector, the
thunk, and the component. Six files, one text input.
And the specific failure the server-cache rung prevents:
Hand-rolled server state in Redux, per resource:
- isLoading, isError, error, data, lastFetched
- a thunk that fetches
- a reducer handling pending/fulfilled/rejected
- manual invalidation after every mutation, in every place that
mutates
- no request deduplication: three components mounting at once
fire three identical requests
- no staleness policy: data from 40 minutes ago looks identical
to data from 2 seconds ago
- no refetch on window focus or reconnect
That is roughly 60 to 120 lines per resource, reimplemented per
resource, and it is the same 60 to 120 lines every time.
The reason it is the same every time is that it is a caching problem, not a state problem, and caching has known solutions.
Mechanics
Climbing the ladder, with the test for each rung
Rung 1, local state. The default. The test for staying: does any component outside this subtree need to read or write it?
function Accordion({ items }) {
const [openId, setOpenId] = useState(null) // nothing outside cares
...
}
Rung 2, lifted state. Two siblings share it, so it moves to their nearest common parent. The test for climbing off: is the common parent so far up that you are threading props through five layers that do not use them?
Rung 3, context. For values that are read widely, change rarely, and are shaped like configuration.
const ThemeContext = createContext(null)
function App() {
const [theme, setTheme] = useState('light')
// MEMOISE THE VALUE. Without this, every render of App gives every
// consumer a new object and re-renders all of them.
const value = useMemo(() => ({ theme, setTheme }), [theme])
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
Context's limitation is precise: there is no selector. Any consumer of a context re-renders when the provider's value changes, regardless of which part it reads. Putting a value that changes on every keystroke into a context read by 200 components re-renders all 200.
Two mitigations:
// 1. Split by change frequency. The classic split is state vs setters,
// because setters are stable and state is not.
<ThemeStateContext.Provider value={theme}>
<ThemeDispatchContext.Provider value={setTheme}>
// 2. If you need selectors, you do not need context, you need a store
// with useSyncExternalStore (which is what Zustand/Redux use).
Rung 4, the server cache. The test: is the server the source of truth? If yes, this rung, always.
// The whole of what the sprawling Redux slice did:
function useArticle(id) {
return useQuery({
queryKey: ['article', id],
queryFn: () => api.getArticle(id),
staleTime: 60_000, // fresh for 60s: no refetch on remount
})
}
function Article({ id }) {
const { data, isPending, error } = useArticle(id)
...
}
What you get without writing it: request deduplication (three components calling useArticle(7)
in the same tick produce one request), a staleness policy, background refetch on window focus
and network reconnect, retry with backoff, garbage collection of unused entries, and
cache invalidation as a first-class operation:
const mutation = useMutation({
mutationFn: updateArticle,
onSuccess: (_, vars) => {
queryClient.invalidateQueries({ queryKey: ['article', vars.id] })
},
})
Rung 5, the global client store. What is left after rung 4 takes the server data is usually small: an editor's undo stack, a multi-step wizard's in-progress values, a canvas's selection, optimistic client-only entities, a websocket-driven presence map.
The test: do distant components both read and write it, and is the server not the source of truth? Both halves matter. Read-only broadcast is context; server-owned is rung 4.
Rung 6, the URL. The most under-used rung.
// Filters in useState: refresh loses them, the back button does nothing,
// and a user cannot send a colleague "the view I'm looking at."
const [status, setStatus] = useState('open')
// Filters in the URL: all three work, for free, and it is also the
// cache key your server cache should use.
const [params, setParams] = useSearchParams()
const status = params.get('status') ?? 'open'
Anything a user would reasonably want to bookmark, share or reach with the back button belongs in the URL: filters, sort, pagination, the selected tab, the open entity's id, a search query.
The Redux async middleware question
If you are on rung 5 with Redux and need asynchrony, there are three classic answers. The comparison is real and the modern answer changes it.
| Thunk | Saga | Observable | |
|---|---|---|---|
| Model | a function with dispatch/getState | generator yielding effect descriptions | RxJS stream of actions in, actions out |
| Learning cost | near zero | high (generators, effect vocabulary) | high (RxJS) |
| Cancellation | manual (AbortController, flags) | first class (takeLatest, cancel, race) | first class (switchMap, takeUntil) |
| Concurrency control | manual | takeLeading/takeLatest/throttle built in | operators built in |
| Testability | mock dispatch, or integration-test it | excellent: yielded effects are plain objects | good, with marble tests |
| Long-lived flows | awkward | its strength (while(true) watchers) | good |
| Debounce/throttle streams | manual | debounce effect | its strength |
| Bundle | ~1KB | ~14KB | RxJS, tree-shaken but substantial |
// THUNK: the same shape as the async function you would write anyway.
const fetchUser = (id) => async (dispatch, getState) => {
dispatch(userLoading())
try { dispatch(userLoaded(await api.getUser(id))) }
catch (e) { dispatch(userFailed(e.message)) }
}
// SAGA: declarative effects, and cancellation you did not write.
function* watchFetchUser() {
// takeLatest cancels an in-flight fetch when a new one starts,
// which removes an entire class of race condition for free.
yield takeLatest('user/fetch', function* (action) {
try { yield put(userLoaded(yield call(api.getUser, action.payload))) }
catch (e) { yield put(userFailed(e.message)) }
})
}
// OBSERVABLE: switchMap gives the same cancellation, plus stream operators.
const fetchUserEpic = (action$) => action$.pipe(
ofType('user/fetch'),
debounceTime(300),
switchMap(a => from(api.getUser(a.payload)).pipe(
map(userLoaded), catchError(e => of(userFailed(e.message)))
))
)
The position: for server data, none of the three. Use RTK Query or TanStack Query, which is rung 4. The middleware comparison is mostly a comparison of three ways to hand-roll a cache.
For the client-state asynchrony that remains, default to thunks, because they add no new
concepts and the team already reads async functions. Reach for saga when you have genuinely
long-lived, cancellable, multi-step orchestration: a background sync loop, a wizard that must
unwind on abandonment, a flow that races a user action against a timeout. Reach for observable
only when the domain is genuinely a stream (a websocket firehose, high-frequency input needing
debounceTime/switchMap) and the team already knows RxJS, because the failure mode is one
person writing epics nobody else can modify.
The honest cost of saga and observable is not the runtime, it is the second language. A saga
codebase has a vocabulary (call, put, fork, takeLatest, race, cancel) that every new
engineer must learn before they can change a data fetch, and the payoff only materialises if you use
the cancellation and orchestration primitives. If your sagas are takeEvery wrappers around
fetches, you have paid the learning cost and bought nothing.
A worked example: a 40-slice store reduced to 6
A logistics dashboard. The Redux store had 40 slices and roughly 4,800 lines of store code. A survey of what was actually in it:
Category Slices Lines Correct rung
------------------------------------------------------------------
Cached server responses 23 3,100 4 (server cache)
UI booleans (modal open, drawer open,
which tab) 9 420 1 (local)
Filter/sort/pagination values 4 310 6 (URL)
Theme + locale + current user 1 90 3 (context)
Editor undo stack, canvas selection,
optimistic drafts 3 880 5 (correct!)
------------------------------------------------------------------
40 4,800
Three quarters of the store was a hand-rolled cache, and the remaining quarter was mostly state that had climbed the ladder because "shared" was read as "global."
The 23 server slices, migrated to TanStack Query (the mechanics of this migration are the subject of migrating a Redux store to a server cache):
Before, per resource: a thunk, three reducer cases, five selectors,
isLoading/isError/error/data/lastFetched, and manual invalidation
in every mutation site.
After: a useQuery hook and, where mutated, a useMutation with
invalidateQueries.
3,100 lines -> ~340 lines.
Two bugs disappeared without being fixed, which is the part worth remembering:
1. A dashboard mounted six widgets that each dispatched a fetch for
the same fleet summary. Six identical in-flight requests on every
navigation. Query deduplication collapsed them to one.
2. Editing a shipment updated the detail view but not the list, because
one of four mutation sites had been written before the list existed
and never had the invalidation added. `invalidateQueries({queryKey:
['shipments']})` in the mutation's onSuccess covers all of them by
construction, because invalidation is keyed by data rather than by
call site.
The second is the structural argument for rung 4 over rung 5. Manual invalidation is correct only if every current and future mutation site remembers; key-based invalidation is correct because the cache, not the caller, knows who holds the data.
The 9 UI-boolean slices moved back to rung 1. Each was a modal or drawer open flag. Seven were genuinely local to one component. Two were opened from a toolbar and rendered elsewhere, so they went to a small context rather than the global store.
The 4 filter slices moved to the URL, and this produced the most user-visible change:
Before: a support engineer describing a problem sent a screenshot,
because the URL was /dashboard for every possible view.
After: /dashboard?status=delayed®ion=on&sort=-eta&page=3
Shareable, bookmarkable, and the back button steps through
filter changes.
Second-order effect: the filter values became the natural query key,
useQuery({ queryKey: ['shipments', {status, region, sort, page}] })
so navigating back to a previous filter set is now an instant cache
hit rather than a refetch. The URL and the cache key are the same
information.
That the URL is also the cache key is not a coincidence: both are asking "what identifies this view of the data."
Final store: 6 slices, ~700 lines, holding the editor undo stack, canvas selection, optimistic drafts, and a small websocket-driven vehicle presence map. All four are genuinely client-owned, written by distant components, and have no server source of truth, which is exactly the rung-5 test.
The saga question, resolved. The codebase had 11 sagas. Nine were takeEvery wrappers around
fetches and were deleted with the server-cache migration. Two remained and justified the library:
1. A bulk-reassign flow: select N shipments, dispatch reassignment,
show progress, allow cancellation mid-flight, unwind partially
applied changes on cancel. `race` between the work and a cancel
action, with a `finally` block for the unwind.
2. A background sync that reconciles offline edits when connectivity
returns: a `while(true)` watcher with backoff and a `takeLeading`
guard against overlapping runs.
Both use cancellation and long-lived orchestration, which is what saga is for. The other nine paid the vocabulary cost and used none of it. That ratio, two justified out of eleven, is the usual finding.
Production evidence
Redux Toolkit's own documentation recommends RTK Query for server state and describes hand-rolled fetching in reducers as the pattern it exists to replace. The Redux maintainers' public position that "you probably don't need Redux for server data" is the strongest available evidence, because it comes from the library the pattern is being moved off.
TanStack Query's deduplication, staleTime/gcTime model, refetch-on-focus, and
invalidateQueries are documented behaviours, and the deduplication guarantee (identical query keys
in the same tick share one request) is the specific property that removes the duplicate-fetch class of
bug.
useSyncExternalStore was added in React 18 specifically so external stores can integrate with
concurrent rendering without tearing, and Redux, Zustand and Jotai all use it. Its existence is the
API-level acknowledgement that context is not a store: it exists to provide the selector-granular
subscription context lacks.
React's own documentation recommends memoising context values and splitting contexts by change frequency, which is the documented mitigation for the no-selector limitation.
Remix and the Next.js App Router both push filter and pagination state into the URL as the default
pattern, with loaders and searchParams reading from it, which is a framework-level endorsement of
rung 6.
The debate
Is Redux dead? No, and the useful version of the question is what is left for it. After server data moves to a query library and view state moves to the URL, what remains is small enough that Zustand or Jotai is often a better fit than Redux, because the ceremony no longer buys anything. Redux still wins where you want strict action-log debuggability, time-travel, or a large team that benefits from a rigid, uniform pattern. The position: choose Redux deliberately for its discipline, not by default for its ubiquity.
Is context a state manager? No. It is dependency injection with no selector granularity. The counter-argument, that you can split contexts finely enough to fix this, is true and it is how you end up hand-building a store with worse ergonomics. Use context for config-shaped, low-frequency values; use a store when you need selectors.
Should everything shareable go in the URL? Nearly everything a user would want to bookmark or
share. The limits are real: URL length, and anything sensitive, since URLs land in browser history,
server logs, and Referer headers. A draft's contents do not go in the URL; a filter set does.
Thunk, saga or observable? For server data, none. For what remains, thunk by default. Saga
earns its vocabulary only if you use cancellation and long-lived orchestration, and the common
outcome is a codebase paying the learning cost for takeEvery wrappers. Observable earns its place
only when the domain is a stream and the team already knows RxJS, otherwise you get code one person
can maintain.
Is useState really the default even on a large app? Yes, and the discipline is enforced at
review time by asking one question: who outside this subtree reads or writes this? The cost of
being wrong downward is a small refactor; the cost of being wrong upward is a slice nobody dares
delete three years later.
Follow-up Q&A
"How do you decide where a piece of state lives?"
Start at local state and climb only when the current rung genuinely cannot hold it. Local, then lifted to the nearest common parent, then context for config-shaped low-frequency values, then a server cache for anything the server owns, then a global client store for client-owned state that distant components both read and write, then the URL for anything a user would bookmark, share or reach with the back button. The single question that resolves most cases is whether the server is the source of truth: if it is, it belongs in a query cache, not in a client store, no matter how many components read it.
"Why is server data not global state?"
Because it is a cache, and caching has requirements that state management does not model: deduplication of concurrent identical requests, a staleness policy, background refetch on focus and reconnect, retry, garbage collection, and invalidation keyed by what the data is rather than by which code path changed it. Hand-rolling that in reducers costs roughly a hundred lines per resource and reproduces the same bugs each time: duplicate in-flight requests, and a mutation site that forgot to invalidate a list it did not know existed. Key-based invalidation is correct by construction because the cache knows who holds the data; caller-based invalidation is correct only while everyone remembers.
"What is context actually for, and what is its limitation?"
It is a transport mechanism for values read widely and changed rarely: theme, locale, the
authenticated user, a feature flag set. Its limitation is that it has no selector: every consumer
re-renders when the provider value changes, regardless of which field it reads. So a value that
changes on every keystroke, in a context read by 200 components, re-renders all 200. Mitigate by
memoising the provider value and splitting contexts by change frequency, most usefully state from
setters since setters are stable. If you need selectors, you need a store using
useSyncExternalStore, which is what Redux and Zustand do.
"When would you choose saga over thunk?"
When you need cancellation and long-lived orchestration that you would otherwise hand-write: a
multi-step flow that must unwind on abandonment, a background sync loop with backoff and a guard
against overlapping runs, a flow racing a user action against a timeout. Saga gives takeLatest,
race, cancel and while(true) watchers as primitives. The cost is a second vocabulary every
engineer must learn before they can change a data fetch, and in most codebases the majority of sagas
turn out to be takeEvery wrappers around fetches, which pay that cost and use none of the
primitives. Audit that ratio before adopting or keeping it.
"What belongs in the URL, and what does not?"
Filters, sort order, pagination, the selected tab, the open entity's id, a search query: anything a
user would bookmark, share with a colleague, or expect the back button to step through. Not in the
URL: anything sensitive, because URLs are stored in browser history, server access logs and
Referer headers, and anything large, because of length limits. A useful side effect is that the URL
parameters are usually exactly the right query cache key, since both answer the same question about
what identifies this view.
"What is left in a global store after all this?"
Client-owned state with no server source of truth that distant components both read and write. In practice: an editor's undo stack, a canvas selection, a multi-step wizard's in-progress values, optimistic client-only entities, and live push-driven data like a presence map. In one audit that was 6 slices and about 700 lines, down from 40 slices and 4,800, after 23 slices of cached server responses went to a query library, 9 UI booleans went back to local state, and 4 filter slices went to the URL.
Common misconceptions
"Shared means global." Shared between two siblings means lifted. Global means distant components both read and write it and the server does not own it.
"Context is a lightweight Redux." It has no selectors, so every consumer re-renders on every value change. It is transport, not storage.
"A query library is just fetch with extra steps." It is deduplication, staleness, background refetch, retry, garbage collection and key-based invalidation. Those are the parts that get hand-written wrong.
"Filters in useState are fine." They are, until a user refreshes, presses back, or tries to
send someone the view they are looking at.
"Saga is more testable, so it is better." Yielded effects being plain objects makes unit tests easy, and the tests then verify a sequence of effects rather than a behaviour. The value of saga is cancellation and orchestration, not testability.
"RTK Query and TanStack Query are competitors to Redux." RTK Query is part of Redux Toolkit. The choice is not framework-versus-framework, it is server-cache rung versus client-store rung.
Interview delivery note
Say this verbatim: "Most oversized stores are a hand-rolled cache. The first question for any piece of state is whether the server is the source of truth, because if it is, it belongs in a query cache with key-based invalidation, not in a client store with invalidation you have to remember at every mutation site." It reframes state management as a caching problem, which is the reframe the interviewer is looking for.
The senior-versus-staff separator is the structural argument for key-based invalidation. A senior engineer says a query library saves boilerplate. A staff engineer says manual invalidation is correct only while every current and future mutation site remembers to do it, whereas invalidating by query key is correct by construction because the cache knows which entries hold that data, and then names the bug this prevents: a mutation written before a list view existed, which nobody ever went back to update.
The second signal is auditing rather than adopting. Saying "before keeping saga, I would count how
many of the sagas use cancellation or long-lived orchestration, because if most are takeEvery
wrappers around fetches then the team is paying for a second vocabulary and using none of it" shows
you evaluate a tool against its actual use, not its feature list.
Further reading
- Redux Toolkit documentation on RTK Query, including the maintainers' guidance that server cache state does not belong in hand-written reducers.
- TanStack Query documentation on query keys,
staleTimeversusgcTime, deduplication andinvalidateQueries. - React documentation on
useSyncExternalStore, and why external stores need it under concurrent rendering. - React documentation on Context, including memoising the provider value and splitting by change frequency.
- The Redux to server cache migration page in this chapter, for the mechanics of doing this incrementally.