Local state belongs to one component or a small subtree (an open dropdown, an input value), global client state is app-wide data the client owns (theme, session, cart), and server state is a cached copy of data the backend owns, which can go stale at any moment.
Local: useState or useReducer, kept as close as possible to where it is used. Most state in a healthy app is local.
Global client state: the app is its source of truth. Context for rarely changing values, a store (Redux Toolkit, Zustand, Jotai) for shared data that changes often.
Server state: you only borrow it, so it needs caching, deduplication, refetching and invalidation: TanStack Query, RTK Query, SWR or router loaders.
URL state: filters, the page number and the selected tab live in the URL, so a link reproduces the view.
Mixing the categories causes most of the trouble: copying server data into a global store means writing by hand the cache a query library already has.
ReactMiddle
State Management
Local vs global vs server state, Context and its re-render cost, Redux Toolkit, Zustand, Jotai, URL as state, choosing a store
Questions closed
0 / 21
- 0Review
- 8Asked often
Questions
The UI dispatches an action (a plain object that describes what happened), the store runs the reducer (state, action) => newState, and subscribed components re-read their part through selectors. Reducers must be pure so that the same state and action always give the same result.
Single store: one state tree, changed only by dispatching actions, so every change is visible and replayable in Redux DevTools.
Pure: no API calls, no Date.now() or Math.random(), no mutation of the arguments. Side effects belong in thunks or listeners.
Immutable updates: useSelector compares results by reference; a mutated object keeps its reference, so the UI does not re-render and time-travel shows a wrong history.
In Redux Toolkit you write "mutating" code inside createSlice, and Immer turns it into an immutable update.
No. Context is a dependency injection mechanism: it passes a value down the tree but does not store, update or optimize anything. Combined with useState or useReducer it covers small, rarely changing global state; a store adds selective subscriptions, middleware and devtools.
What Context gives: no prop drilling. The state still lives in a component, and every consumer re-renders on every change.
What a store adds: components subscribe to a slice through selectors and re-render only when it changes; the state lives outside React, so event handlers, tests and non-React code can read it; DevTools, middleware, persistence.
Good fit for Context: theme, locale, the signed-in user, feature flags, an injected dependency such as an API client.
Good fit for a store: a cart, an editor, a dashboard where many components read and write the same fast-changing data.
Server data belongs to neither: it goes into a query cache.
The search query and the user share one context, and the value is a new object on every render, so each keystroke changes the context value and every consumer, including Avatar, re-renders although user did not change.
Context has no selectors: reading only user still subscribes the component to the whole value.
useMemo on the value alone does not help here, because query really changes on every keystroke.
The fix moves query into its own context (or into local state of the search page, where it usually belongs), so the user context changes only on sign-in and sign-out.
When many unrelated fields have to stay together, that is the sign to move them into a store with selectors.
Redux Toolkit is the official way to write Redux: configureStore sets up DevTools, thunks and development checks in one call, and createSlice generates the action creators and the reducer from one object, with Immer allowing mutating syntax.
Less boilerplate: no action type constants, no separate action creators, no switch.
Immer: state.items.push(x) inside a slice reducer produces a new immutable state.
Safety checks: in development the store warns about mutations outside reducers and about non-serializable values in state or actions.
Async: createAsyncThunk for request lifecycles, the listener middleware for reactions to actions, RTK Query for server data with caching.
TypeScript: types are inferred from the slices; useSelector.withTypes<RootState>() gives typed hooks without hand-written wrappers.
The reducer reassigns the state parameter instead of changing the draft or returning a new value. Reassignment only rebinds a local variable; Immer sees neither a mutation nor a return value and keeps the old state.
Inside a slice reducer there are exactly two valid ways to update: mutate the draft (state.items = []) or return a completely new state. Never both in one reducer: Immer throws if you modify the draft and return a different object.
The same trap catches todoAdded: (state, action) => state.push(action.payload): the arrow returns the result of push, a number. TypeScript flags it, plain JavaScript fails at runtime.
ESLint's no-param-reassign with ignorePropertyModificationsFor: ['state'] catches the reassignment while still allowing draft mutations.
useSelector runs the selector after every dispatched action and re-renders the component only if the result changed by reference (===). A selector that builds a new array or object each time, such as state.todos.filter(...), therefore re-renders the component on every action in the app.
Return primitives or existing references (state.todos.items, state.user.name); call useSelector several times instead of returning an object of fields.
Derive data with a memoized selector: createSelector (Reselect, re-exported by Redux Toolkit) recomputes only when its inputs change.
shallowEqual as the second argument helps for a small object of fields.
In development React Redux warns when a selector returns a different reference for the same input ("Selector unknown returned a different result when called with the same parameters").
A memoized selector with arguments needs one instance per component (useMemo(() => makeSelectItem(), [])), or components sharing it keep invalidating its cache.
Not in reducers. Thunks made with createAsyncThunk run requests and dispatch pending, fulfilled and rejected actions that slices handle in extraReducers; the listener middleware reacts to actions; and for plain data fetching RTK Query replaces both with a cache.
**createAsyncThunk**: one async function, three lifecycle actions; rejectWithValue passes a typed error; promise.abort() and the signal argument cancel it.
Listener middleware (createListenerMiddleware): "when action X happens, do Y" logic such as analytics, syncing to localStorage, a debounced search. It replaces most uses of sagas.
RTK Query (createApi): endpoints with caching, deduplication, tag-based invalidation and generated hooks. Hand-written fetch thunks for server data usually mean RTK Query would fit better.
Redux-Saga and redux-observable still appear in older codebases, but new code rarely needs them.
RTK Query is the data-fetching and caching layer built into Redux Toolkit: you describe endpoints in createApi, it generates hooks, and the cache lives in the Redux store. Pick it when the app already uses Redux; without Redux, TanStack Query is the usual default.
Same core ideas: a cache keyed by endpoint and arguments, deduplication, polling, refetch on focus, generated useGetTodosQuery and useAddTodoMutation hooks.
Invalidation by tags: queries declare providesTags, mutations declare invalidatesTags, and RTK Query refetches whatever matches. TanStack Query invalidates by query key prefix instead.
Redux integration: cached data shows up in Redux DevTools, other slices can react to its actions, and onQueryStarted with updateQueryData handles optimistic updates.
TanStack Query advantages: no Redux dependency, first-class Suspense hooks (useSuspenseQuery), adapters for other frameworks and a larger ecosystem.
OpenAPI codegen: @rtk-query/codegen-openapi generates the whole API slice from a schema.
Store collections normalized: each entity exactly once in an entities map keyed by id, an ids array for order, and references to other entities by id. createEntityAdapter generates that shape, ready reducers such as addOne, upsertMany and updateOne, and memoized selectors.
Why normalize: nested copies of the same user inside posts and comments drift out of sync, and updating one deep field means rebuilding the whole tree.
Lookups by id take O(1) instead of an array.find.
Fewer re-renders: updating one entity changes only its object reference, so memoized rows for the other items do not re-render.
**sortComparer** keeps ids sorted on every insert.
For server data, a query cache keyed by id gives the same benefits; normalize by hand the state the client owns or edits heavily.
Zustand is a small hook-based store: create returns a hook, state and actions live in one object, and components subscribe with a selector. It needs no provider, no actions or reducers and much less code than Redux Toolkit, at the cost of fewer conventions.
Subscription: useCart((s) => s.items.length) re-renders only when that value changes (Object.is).
No provider: the store is a module-level singleton; for a store per request in SSR or per test, create it inside a Context instead.
Outside React: useCart.getState() and useCart.setState() work in event handlers and plain modules.
Middleware: persist, devtools and immer are opt-in.
Pick Redux Toolkit when the team wants strict structure, the time-travel DevTools workflow or RTK Query, or the codebase already uses Redux. Pick Zustand for small to medium shared state with little ceremony.
The selector returns a new object on every call. Zustand 5 passes selector results to useSyncExternalStore, which compares them with Object.is, so React sees a changed snapshot on every check and loops until it throws. Zustand 4 only re-rendered too often.
Fix 1: wrap the selector in useShallow, which compares the object's fields one level deep and returns the previous reference when they match.
Fix 2: select each value separately; actions created in create are stable, so useCart((s) => s.clear) never triggers a render.
The same rule holds for useSelector in React Redux and for any useSyncExternalStore snapshot: return a stable reference for unchanged data.
In Jotai, state is many small atoms instead of one tree: a component subscribes to exactly the atoms it reads, and derived atoms compute from other atoms like spreadsheet cells. It fits fine-grained, interdependent state such as editors, complex forms and canvases.
Atom: atom(0) holds a value; useAtom works like useState, but the value is shared.
Derived atoms: atom((get) => get(a) * 2) recalculates only when its dependencies change; writable derived atoms encapsulate update logic.
Precise re-renders: each atom is a separate subscription, so no selectors are needed.
Async atoms work with Suspense.
Trade-off: logic spreads across many atoms, so the whole state is harder to see at once than in a Redux or Zustand store. Recoil, Meta's earlier atomic library, is archived; Jotai is the maintained choice.
MobX makes state observable, records which observables each component reads during render, and re-renders exactly those components when the values change. Redux keeps one immutable state tree that only dispatched actions change through pure reducers, and components subscribe through selectors.
Updates: in MobX you mutate state inside actions (this.items.push(item)); in Redux a reducer returns a new state, which Immer builds from a draft in Redux Toolkit.
Subscriptions: observer subscribes a component to whatever it reads, with no selectors; useSelector re-renders when the selected value changes by reference.
Derived data: a store getter is a computed value, cached until its inputs change; Redux uses createSelector.
Opposite habits: MobX prefers passing objects down and reading fields late, inside the observer that renders them; Redux selects primitives early. A value read outside any observer is not tracked, so a forgotten observer is the classic MobX bug.
Trade-off: class-based stores and little boilerplate, but implicit updates; Redux gives one action log, time-travel DevTools and stricter conventions.
Use the store's persistence middleware, such as Zustand's persist or redux-persist: it saves selected state to localStorage (or IndexedDB) and restores it on start. The hard parts are choosing what to save, migrating old saved data and the timing of rehydration.
Persist little: partialize (Zustand) or a whitelist (redux-persist) for preferences, drafts and a cart. Not server data that a query cache refetches anyway, and not tokens or personal data that any script on the page can read.
Version and migrate: a field renamed in a new release meets old saved JSON; version plus migrate converts it instead of crashing on load.
Rehydration timing: with async storage the first render sees the defaults; Zustand's persist.hasHydrated() and onFinishHydration or redux-persist's PersistGate hold back the UI that depends on saved state.
SSR: the server has no localStorage, so the server HTML uses defaults and the client applies saved state after hydration to avoid a mismatch.
Storage errors: quota limits and some private modes make setItem throw; the app must keep working without persistence.
Put state in the URL when a user would expect to share it, bookmark it or go back to it: search queries, filters, sorting, pagination, the selected tab or the open item. The URL then becomes the source of truth, and components read it instead of copying it.
Wins: a link reproduces the exact view, the Back button undoes a filter change, a reload keeps the state, and SSR can render the right page.
Read, do not copy: derive values from useSearchParams on each render; mirroring them into useState creates two sources of truth that drift apart.
History hygiene: replace the history entry for keystroke-level updates such as a search box, push a new one for deliberate steps such as the next page.
Keep out: secrets and personal data (they end up in logs and the Referer header), large objects, and short-lived UI such as hover or an open dropdown.
Parse and validate params: the URL is user input, so ?page=abc must fall back to a default.
Server data is a cache of something the backend owns, so it needs loading and error states, deduplication, refetching, invalidation and garbage collection. Putting it into a global store means writing that cache by hand; TanStack Query or RTK Query already implements it.
Hand-written cost: isLoading flags everywhere, duplicate requests from two components, stale data after a mutation, no refetch on window focus, race conditions between responses.
Query libraries: key the data (['user', id]), share one request between components, mark data stale and refetch it, invalidate after mutations, support optimistic updates.
What stays in the store: state the client owns, such as UI preferences, a draft, a wizard step, a selection.
After this split, the global store in many apps shrinks to a few small slices, and some apps no longer need one at all.
Split it by feature: each feature owns its slice, selectors and actions in its own folder, other features use only its public selectors, and slices are added when the feature's code loads (combineSlices with lazy slices in Redux Toolkit 2, or separate small stores in Zustand).
Ownership and boundaries
One slice per domain (cart, checkout), not per page; a feature exports selectors and action creators, never its state shape.
Cross-feature reactions go through actions handled in extraReducers or the listener middleware, never through one feature writing into another's state.
Loading and size
combineSlices(...).withLazyLoadedSlices<LazySlices>() plus slice.injectInto(rootReducer) adds a reducer when its route chunk loads, so the first bundle carries only what the first screen needs.
In Zustand the idiom is several small stores per feature rather than one giant store; they are independent modules and split with the code naturally.
Keeping it lean
Move server data to RTK Query or TanStack Query first: it is usually the largest and most tangled part of a legacy store.
Derive instead of storing: totals, filtered lists and flags come from memoized selectors, so each fact has one source of truth.
Type the root state from the reducers and use typed hooks (useAppSelector), so a renamed field breaks the build, not production.
Incrementally, never as a big-bang rewrite: first swap createStore for configureStore, so all old code runs unchanged in the new store, then move server data to RTK Query or TanStack Query, convert reducers to createSlice as features are touched, and retire connect and sagas last.
The order
The store: configureStore takes the old reducers and the saga middleware (the code) and adds DevTools and development checks that throw on state mutations and warn about non-serializable values. In old code they find real bugs, so fix those rather than turning the checks off.
Server data first: fetch sagas, FETCH_X_REQUEST/SUCCESS/FAILURE reducers and loading flags are usually the biggest part; replace them endpoint by endpoint with RTK Query (stays in the store) or TanStack Query (leaves it).
Reducers: rewrite a reducer as a createSlice when its feature changes; until every dispatcher is migrated, the slice still answers old action types through builder.addCase('ADD_TODO', ...).
Sagas: what remains (analytics, sync, sockets) moves to the listener middleware; both run side by side meanwhile.
Components: replace connect with useSelector and useDispatch file by file; connect still works in React Redux 9, so there is no deadline.
Keeping it safe
Upgrade the packages first: RTK 2 removed the object form of extraReducers and requires middleware to be a callback, so old setup code breaks on its own.
Cover the selectors and reducers you are about to replace with tests.
Allow only the new patterns in new code, or the legacy part keeps growing while you migrate.
When a flow has several steps and events that are valid only in some of them: a checkout, an upload with retry and cancel, a connection that reconnects. A machine is in exactly one state at a time and lists the events each state accepts, so impossible states and impossible transitions cannot happen.
What it fixes
Impossible combinations: isSubmitting, isError and isSuccess allow eight combinations, four of them nonsense; one state value allows exactly the four that exist.
Impossible transitions: a discriminated union fixes the shape of the data but not the order of events; a machine also ignores a second submit while submitting, or a late failure after success, without an if in every handler.
Effects bound to states: in XState an invoked actor starts when its state is entered and stops when it is left (a fromPromise actor gets an AbortSignal), and after replaces hand-managed timers.
A shared picture: the statechart can be drawn and reviewed with designers and QA; each transition is testable without rendering.
What it costs
A new model (states, events, guards, actions, actors), and XState v5 differs from most v4 tutorials.
More code than a useState toggle: a dropdown or a modal does not need a machine.
One more library; for a single component a transition table in useReducer (the code) gives most of the benefit.
Server data still belongs in a query cache, and a machine can sit next to a Redux or Zustand store instead of replacing it.
Classify the state first, then pick the lightest tool per category: local state in components, server state in a query cache, shareable view state in the URL, and a store only for the client-owned global state that remains. Many apps end up with a small store or none at all.
Questions to ask
Who owns the data: the server (query cache), the URL (router) or the client?
How many components read and write it, and how often does it change? Rarely changing values fit Context; frequent updates read by many components need selectors.
Must it survive a reload (persist, the URL), work offline, sync across tabs?
Typical picks in 2026
TanStack Query or RTK Query for API data; with React Router or Next.js, loaders and Server Components cover much of it.
Context for theme, locale, the session and injected services.
Zustand for small to medium shared client state with little ceremony.
Redux Toolkit for large teams and complex client-side domains (editors, offline-first apps), or an existing Redux codebase.
Jotai for fine-grained, interdependent state.
Team and cost
Consistency matters more than the library: one documented approach beats three stores.
Measure re-render problems with the React DevTools Profiler before adding a store for performance.