Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~26 min· topic 6 of 6

Topic 15.6

Choosing a State Tool

In one line

Choose by the kind of state, not by fashion: local state and the URL first, a query cache for server data, context for rare global values, and a small store (Zustand, Redux Toolkit, Jotai) only for busy global client state, introduced and migrated one feature at a time.

Think of it like this

A toolbox. A carpenter doesn't use a hammer for every job, and doesn't buy a whole workshop to hang one picture. They look at the job first (a nail, a screw, a cut) and pick the smallest tool that does it well. Choosing a state tool works the same way: name the kind of state first (Topic 15.1), then pick the lightest tool that handles it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Decision guide
A short list of questions that leads to the right tool for a kind of state.
Server-state tool
A library that caches data from a server and handles loading, sharing, refetching and invalidation.
Client store
A store for data made in the browser, like Zustand, Redux Toolkit or Jotai.
Query key
The array that names a piece of cached server data, like ["products", "tea"].
Facade
Your own simple functions or hooks that hide which library is used underneath.
Gradual migration
Moving an app to a new tool one feature at a time, with old and new working side by side.
Boilerplate
Repetitive setup code that you must write before doing the real work.

Step by step

01Name the kind of state

Use Topic 15.1's questions for each piece of data: who owns the truth and who reads it? Most answers land on local state, the URL or a query cache before any library is considered.

02Follow the decision guide

Walk the chart top to bottom. Stop at the first tool that fits.

Follow the decision guidediagram
Rendering diagram…

03Wire the query cache and the client store together

Client state that changes what you fetch goes into the query key. The store never holds the fetched data.

Products.jsxwhole filejsx
const category = useShop((s) => s.category);         // client state
const { data } = useQuery({
  queryKey: ["products", category],                  // ...feeds the server query
  queryFn: () => fetchProducts(category),
});

04Hide the tool behind a facade

Export hooks named after what the app needs, from one module per feature. Components import useCartCount, not zustand or react-redux.

src/features/cart/index.jswhole filejsx
// Public API of the cart feature. Today: Zustand. Yesterday: context.
export const useCartCount = () => useCartStore((s) => s.items.length);
export const useCartItems = () => useCartStore((s) => s.items);
export const useAddToCart = () => useCartStore((s) => s.add);

05Swap one feature at a time

Move one feature, run the tests, ship it, then move the next. Old and new tools run side by side. Remove the old dependency only when nothing imports it.

Swap one feature at a timediagram
Rendering diagram…

Try it yourself

  1. 1

    Make the cache stale

    In the first example, change staleTime: 60_000 to staleTime: 0. Predict what happens on back to tea. Run it: the cached list shows instantly and a background request runs (network: GET /products?cat=tea), because stale data is shown while it is refetched.

  2. 2

    Store the products by mistake

    Add products: [] to the store and copy data into it with an effect. Predict the problems: an extra render after every fetch, and two copies that can disagree after a refetch. Then delete it again: the query cache already is the store for server data.

  3. 3

    Switch the implementation

    In the facade example, render only <NewApp /> and delete the old implementation. Nothing else changes. That's the moment a real migration ends.

Code & diagrams

TanStack Query for server data, Zustand for client state New tab

The store holds only the category and the cart; the products never enter it. Switching back to `tea` makes no request: the query cache still has `["products", "tea"]` and it isn't stale yet. Adding to the cart re-renders only `CartBadge` among the logged components.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

  render CartBadge: 0
  network: GET /products?cat=tea
add first product
  render CartBadge: 1
open mugs
  network: GET /products?cat=mugs
back to tea (cached)
on screen: Green tea Add
Migrating behind a facade: same components, new tool New tab

`Badge` and `AddButton` are written once and work with both implementations, so the switch touched only the cart module. The new version is also cheaper: `AddButton` no longer re-renders when the cart changes, because it selects a stable action instead of reading the whole context.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

  old render Badge
  old render AddButton
  new render Badge
  new render AddButton
click old
  old render Badge
  old render AddButton
click new
  new render Badge
A feature folder that hides its state toolbash
src/features/cart/
├── index.ts          # public API: useCartCount, useCartItems, useAddToCart, <CartProvider>
├── cartStore.ts      # Zustand store (internal: not imported outside this folder)
├── cartApi.ts        # TanStack Query hooks for the server cart: useServerCart, useCheckout
└── CartPanel.tsx     # UI that uses the public API

# ESLint rule that keeps the facade honest (eslint.config.js):
# "no-restricted-imports": ["error", { "patterns": [{ "group": ["**/cartStore"], "message": "Import from features/cart instead." }] }]

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Picking the store before naming the state

Start a new app by installing Redux and putting the user, products, filters, form fields and modal flags into it.

terminal
$ Profiler
── what you'll see ──
Typing one letter in a form field re-renders the header, the product grid and the sidebar

Break #2

A big-bang rewrite

Replace Redux with Zustand across the whole app in one pull request.

terminal
$ Code review
── what you'll see ──
412 files changed, 9,800 additions, 11,200 deletions. Merge conflicts with every open branch.

Myth vs fact

Myth

Serious React apps need Redux.

Fact

Many large apps run on a query cache, the URL, local state and a little context or Zustand. Redux is a good choice for some teams and problems, not a requirement.

Myth

Using two state libraries in one app is a mistake.

Fact

Using a server-state library plus a client store is the standard modern setup. The mistake is two tools holding the same data.

Myth

Switching state libraries means rewriting the app.

Fact

With facade hooks, a migration is a series of small, safe pull requests, one feature at a time.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Server Components (Phase 20) move a lot of data fetching to the server, which shrinks client state further: data rendered on the server needs no client cache at all. Client stores then hold mostly interactive UI state, often scoped per page.

  • ▸

    Bundle size matters on mobile: roughly, Zustand is about 1 KB gzipped, Jotai's core a few KB, Redux Toolkit with react-redux around 12 to 15 KB, TanStack Query around 12 to 14 KB. Check the current numbers with your bundler's analyser (Topic 23.4) instead of trusting blog posts.

  • ▸

    The facade also helps testing: tests render components against the public hooks, and you can provide a fake implementation through a context or a module mock without knowing which library sits behind it.

  • ▸

    When choosing, also weigh SSR support (per-request stores), DevTools quality, TypeScript inference, and how long the library has been maintained. A library's GitHub activity and release history are part of the decision; Recoil's archiving is a reminder.

Remember this

  1. 1

    Start with no library. Local useState/useReducer, lifting state, composition (passing components as children) and the URL cover most state in most apps. Add context for values that many components need but that change rarely: theme, locale, the current user, a feature-flag set, or a stable store/client object. Only when something is both global and busy (changes often, read in many places, or updated from outside React) do you need an external store.

  2. 2

    Server data gets a server-state tool, whatever else you use: TanStack Query (Topic 14.6), SWR, RTK Query if the app already uses Redux, Apollo or Relay for GraphQL, or the framework's own loaders and Server Components (Phase 20). This one choice usually removes most of what people used to put in a global store, along with the loading flags and cache invalidation code.

  3. 3

    For the remaining global client state, pick by team and shape. Zustand: smallest API, no provider, selectors, great default for most apps. Redux Toolkit: strongest conventions, best DevTools history, listener middleware and entity adapters, ideal for large teams and complex client logic, or an existing Redux codebase (add RTK Query for data). Jotai: many small, independent or derived pieces (editors, dashboards with many widgets, per-item state). MobX: teams that prefer mutable, class-based models with automatic tracking. XState: complex flows where the order of events matters. These can coexist: a state machine for a checkout flow inside a Zustand app is normal.

  4. 4

    The most common modern stack is TanStack Query for server state + a small client store (or just context) for the rest + the URL for navigation state. The query cache owns everything that came from the server; the store holds only what the browser created (the cart before checkout, UI preferences, a draft). The two meet at query keys: client state such as the selected category goes into the query key (["products", category]), so changing it fetches or reuses the right cached data. Don't copy query data into the store; read it from the cache where you need it.

  5. 5

    Migrate gradually, never in one big rewrite. First put a facade in front of the old tool: components call your own hooks (useCartCount(), useAddToCart()), never the library directly. Then swap the implementation behind the facade one feature (one slice, one context) at a time, keeping both tools installed during the move. For Redux-to-query moves, convert one endpoint at a time from thunks to RTK Query or TanStack Query, and delete the old status flags as you go. Measure before and after with the Profiler (Topic 16.6) so the migration is justified by numbers, not taste.

  6. 6

    Warning signs that the design needs work: the same data stored in two places; components re-rendering on unrelated changes (check with the Profiler or render logs); loading flags and fetch calls in reducers; deep prop drilling through many layers; one giant context or store file that every feature edits. Each sign maps to a fix from this phase: one source of truth, selectors or split contexts, a query cache, context or a store, and slices.

Explain it without notes

01

How do you decide which state management tool to use in a new React app?

02

Why is "TanStack Query + a small client store" such a common combination, and how do the two interact?

03

How would you migrate a large app from one state library to another?

04

What signs tell you an app's state design needs work?

Practice

01

For a chat app, choose a home for: the list of conversations, the open conversation id, the message being typed, the online status of friends (pushed over a WebSocket), and the notification sound setting. Justify each.

02

Write facade hooks useTheme() and useSetTheme() for a theme that currently lives in context, so you could later move it to Zustand without touching components.

Trade-offs

  • ↔

    Fewer tools means less to learn, but forcing one tool on every kind of state (everything in Redux, or everything in context) costs re-renders and hand-written cache code. Two well-chosen tools usually beat one overstretched one.

  • ↔

    A facade adds a thin layer and some indirection, but it keeps the choice of library reversible, which matters in a codebase that will outlive today's favourite tool.

Done when you can

  • I can walk the decision guide for any piece of state.

  • I keep server data in a query cache and client state in a small store or context.

  • I put client state that affects fetching into query keys.

  • I can name when Zustand, Redux Toolkit, Jotai, MobX or XState fits.

  • I can hide a state library behind facade hooks.

  • I can plan a gradual, feature-by-feature migration.