Stop asking “which state library?” and start asking “what KIND of state is this?” Split state into server · URL · local · shared · form, pick the right tool per kind, and ~90% of your “state management” problem disappears — no giant store required.
The junior question is “Redux or Zustand?” The Lead question is “what kind of state am I holding, and what's the right tool for that?” Almost all app state falls into five concerns, each with a different owner and lifecycle: [developerway]
| Kind of state | Example | Right tool |
|---|---|---|
| Server (remote/async) | hotel list, prices, user bookings | TanStack Query / SWR |
| URL (query params) | search filters, dates, tab, page # | nuqs / the router |
| Local (one component) | dropdown open, tooltip, input value | useState / useReducer |
| Shared (UI, cross-component) | theme, current user, sidebar | props → Context → store |
| Form | checkout fields, validation | RHF / form lib (a specialised mix) |
The punchline: most of what teams shove into a global store is actually server state or URL state in disguise. Pull those out into purpose-built tools and the “global state” you have left is small, predictable, and easy to reason about. “Break your state into different concerns and you'll find yourself with better solutions.”
“I don't start with a library — I start by naming the state. Server, URL, local, shared, form. Pick the right tool per kind and 90% of the ‘state management problem’ just evaporates.”
This is the distinction the panel is fishing for. Client state you own: it's synchronous, lives in the browser, and you're the source of truth (is the modal open?). Server state you don't own — it's a cache of someone else's data: [Kent C. Dodds]
Synchronous, instantly correct, lives only in the tab. Modal open, selected tab, form draft. useState / Context / Zustand.
Async, goes stale, shared by many components, needs caching, dedupe, retries, invalidation, pagination, optimistic updates. useState can't model any of that.
Putting server data in Redux/Zustand means hand-reinventing caching, request dedupe, background refetch, and invalidation — badly. That's why ~80% of the Redux in legacy apps is just server-state plumbing. A server-state library (TanStack Query / SWR) gives you all of it for free: stale-while-revalidate, dedupe, invalidate-on-mutation (Lesson 08). [TanStack]
// Server state — NOT useState/Redux. The library owns the cache.
const { data, isLoading } = useQuery({
queryKey: ['hotel', id], // cache key
queryFn: () => fetchHotel(id), // dedupe + SWR + retry, free
});
“Server state isn't app state — it's a cache of data the backend owns. Modelling it with useState or Redux means re-implementing caching and invalidation by hand. React Query exists so you don't.”
The category most engineers forget — and the one that matters most for a travel/booking site. Anything that should survive a refresh, be shareable, bookmarkable, work with the back button, and be crawlable by Google belongs in the URL, not React state: search terms, dates, filters, sort, pagination, the active tab. [developerway]
For the platform this is huge: /search?city=tokyo&checkin=…&guests=2 must be a real, linkable, SEO-indexable URL (Lesson 09). The catch is manually syncing state ↔ URL is “a journey full of misery and weird bugs.” nuqs makes a query param behave like useState: [nuqs]
// The URL IS the state — shareable, refresh-proof, crawlable.
const [city, setCity] = useQueryState('city'); // nuqs — syncs to ?city=
“Filters, dates, sort, page — that's URL state, not React state. On a travel site it has to be shareable and crawlable, so the URL is the source of truth. I use nuqs so a query param behaves like useState.”
What's left after server + URL is genuine client state, and you escalate only as far as you need: [react.dev]
useState / useReducer. Most UI state never needs to leave its component. “Zero reason to introduce a library” for a dropdown's open flag.“Context is a transport, not a store: any value change re-renders every consumer. Perfect for theme or current user; the moment it changes often or I'm splitting providers to dodge re-renders, that's the signal to use a real store.”
Only now — for client state that's shared, changes often, and is read selectively — does a store earn its place. The three you must compare: [Zustand]
Create state, use it via a hook. No provider, selector-based granular re-renders, tiny API, SSR/RSC-friendly. The “versatile middle ground” for most apps.
State is atoms; components subscribe to just the atoms they read, so updates are fine-grained by construction. Shines with many small, interdependent pieces of state.
Opinionated, strict unidirectional flow, time-travel DevTools, middleware. Worth it for big apps/teams needing structure & debuggability — but heavier boilerplate.
Jotai = bottom-up (compose atoms); Zustand = top-down (one store + selectors). Both avoid Context's re-render-everything problem.
A provider exists to inject a value into the React tree so descendants can read it via Context. Zustand never enters the tree: create() builds the store as a plain module-level closure — a normal JS object living in the module's scope. Any component that imports the hook subscribes directly to that external object (Zustand uses React's useSyncExternalStore under the hood). There's nothing to “provide” because the store isn't scoped to a subtree — it's a singleton the module already holds.
// store.js — the store IS a module-scoped object. No <Provider> anywhere.
import { create } from 'zustand';
export const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}));
// CartBadge.jsx — import the hook anywhere; nothing wraps these components.
import { useCartStore } from './store';
function CartBadge() {
// selector → re-renders ONLY when items.length changes
const count = useCartStore((s) => s.items.length);
return <span>🛒 {count}</span>;
}
// AddButton.jsx — somewhere else entirely in the tree, no shared ancestor needed
function AddButton({ product }) {
const addItem = useCartStore((s) => s.addItem); // stable, never re-renders
return <button onClick={() => addItem(product)}>Add</button>;
}
Contrast with Context, where you must wrap — <CartContext.Provider value={…}>…</CartContext.Provider> — and only descendants can read. Here CartBadge and AddButton share no provider ancestor; they just both import useCartStore.
The criteria that pick a winner (Makarevich's): simplicity, zero/single provider (no providers hell), granular re-rendering (selectors/atoms, not whole-tree), and React-aligned (hooks, SSR, RSC, unidirectional). On those, Zustand is the common default — “not because it's objectively best, but because it satisfies the criteria.” The modern default stack: TanStack Query + nuqs + Zustand.
“For the client state that's left, I default to Zustand — no provider, selector re-renders, tiny API. Jotai if the state is atomic and interdependent; Redux Toolkit when a big team needs structure and DevTools. The days of putting everything in Redux are over.”
Apply the taxonomy to a hotel search/booking flow and watch the “global store” shrink to almost nothing:
| State | Kind → tool |
|---|---|
| Search results, hotel details, prices, bookings | Server → TanStack Query (SWR cache, dedupe, invalidate-on-book; Lesson 08) |
| City, dates, guests, filters, sort, page | URL → nuqs (shareable + crawlable for SEO; Lesson 09) |
| “Map/list” toggle, modal open, hovered card | Local → useState |
| Theme, locale, auth user | Shared, low-freq → Context |
| Checkout form + validation | Form → React Hook Form |
Concept: state management is a taxonomy — server/URL/local/shared/form — each with a purpose-built tool; the leftover global store is tiny. Trade-off: a single global store is simple to picture but reinvents caching for server data and re-renders everything via Context; specialised tools cost more concepts but remove whole bug classes. Anchor: “We deleted most of a Redux store by moving server data to React Query and search filters to URL state — fewer re-renders, shareable search links that helped SEO, and a fraction of the code.” Impact: at platform scale this is correctness and conversion — crawlable search URLs, instant-feeling cached data, no over-render jank on low-end devices. Invite: “If the team strongly valued strict structure and audit trails over minimalism, I'd run Redux Toolkit for the client slice instead of Zustand — same taxonomy, different store.”
Pick an answer; instant feedback. Push-back style, like the round.
1. A panelist asks “Redux or Zustand?” What's the strongest opening move?
2. The core distinction between server state and client state:
3. Why is putting fetched API data in Redux/Zustand usually the wrong call?
4. On a travel site, where do search filters, dates, sort, and page number belong?
5. What makes URL state especially important for the platform specifically?
6. What's the Context “providers hell” / re-render trap?
7. Given the re-render trap, what is Context genuinely good for?
8. Why do Zustand and Jotai avoid Context's re-render-everything problem?
9. The clean one-line difference between Zustand and Jotai:
10. When is Redux Toolkit still the right choice over Zustand?
11. A teammate wants one big global store for the whole hotel app. Best Lead pushback?
12. The modern “default stack” the taxonomy points to:
13. Zustand needs no provider. Why — and what's the one exception?
0 / 13 answered