Library
00/07 · ~32 min
GUIDEDECK · putting state where it actually belongs

State
Management without
the guesswork.

A 32-minute working session on the question that quietly decides your app's architecture: where does each piece of state live? From React's built-ins to global stores and server-state caches — and the honest trade-offs between them.

~32 MINBEGINNER → INTERMEDIATEREACT-FLAVORED, IDEAS TRANSFER
SCROLL
01 · What counts as state 4 min

The split that decides everything:
client state vs server state.

Before reaching for any library, ask one question of every value: do you own it, or is it a copy of something that lives on a server? Almost every state-management mistake comes from blurring those two — and most "we need Redux" conversations evaporate once you separate them.

Stateany value that changes over time and that your UI reads. The hard part isn't storing it; it's deciding who owns it, who can change it, and who needs to react when it does. Get those three right and the tool almost picks itself.

The two kinds, side by side

  • Client state — born and owned in the browser. A modal being open, the active tab, a wizard step, a form draft, the theme. It's synchronous — you set it, it's there.
  • Server state — the real value lives in a database behind an API; your app holds a cached copy. The user's orders, a product list, search results. It's asynchronous, shared across users, and it can go stale the moment you fetch it.
  • These need different tools. Storing server data in a client store is the most common, most expensive mistake in the whole topic.
Client state you own it · synchronous modal open / closed active tab, form draft theme, wizard step Server state a cached COPY · async user profile, orders product list, search can go stale · shared

Same word, two different problems. Client state is owned and instant; server state is a borrowed copy you must keep fresh.

Server data trapped in local state
function Profile() { const [user, setUser] = useState(null) // a server copy in useState useEffect(() => { fetch('/api/me').then(r => r.json()).then(setUser) }, []) // no cache, no retry, no dedupe, no refresh } // every screen refetches; the copy silently goes stale
Treat it as a cache (Part 4)
function Profile() { const { data: user } = useQuery({ queryKey: ['me'], queryFn: () => fetch('/api/me').then(r => r.json()), }) // caching, dedupe, retry, refetch — for free } // one cache, shared everywhere, kept fresh for you

Most of this deck is really one lesson: stop hand-rolling a cache out of useState + useEffect, and stop putting owned UI state on a server.

02 · React built-ins 5 min

Start with what's already there:
useState, useReducer, Context.

Most apps need far less global state than they think. Reach for a library only when these three genuinely run out of room. For the mechanics of hooks themselves, see Modern React — here we focus on where each one fits.

useState

One value, one component

The default. A piece of state owned by a single component (and maybe passed down a level or two). When two updates depend on the previous value, use the functional form.

const [count, setCount] = useState(0) setCount(c => c + 1) // safe under batching
useReducer

Many transitions, one place

When several actions change related state in structured ways, a reducer keeps the logic in one testable function instead of five scattered setState calls.

function reducer(s, a) { switch (a.type) { case 'inc': return { n: s.n + 1 } case 'reset': return { n: 0 } } }
Context

Avoid prop-drilling

Context is not a state manager — it's a transport. It moves a value down the tree without threading props through every layer. It does no caching and no selective updates.

const Theme = createContext('light') // provide high, read deep const theme = useContext(Theme)

The Context re-render trap

Provider value changes consumer · re-render consumer · re-render consumer · re-render

Any change to a context value re-renders every consumer — even ones that only read an unrelated field. Context has no built-in selectors.

Two things make Context bite. First, putting a fast-changing value (mouse position, a growing list) next to slow-changing ones in a single context. Second, passing a fresh object as the value every render — a new identity always counts as a change.

  • Memoize the value so its identity is stable across renders.
  • Split contexts: one for rarely-changing data, one for the churny stuff.
  • When you need selective subscriptions, that's the signal to reach for a real store (Part 3).
new object every render
// fresh value identity each render → all consumers re-render <AppCtx.Provider value={{ user, theme, setTheme }}> {children} </AppCtx.Provider>
stable identity, split concerns
const value = useMemo( () => ({ user, theme, setTheme }), [user, theme], // identity changes only when these do ) <AppCtx.Provider value={value}>{children}</AppCtx.Provider>
03 · Client global stores 5 min

When client state must be shared widely —
a store with selectors.

Global stores solve the two problems Context can't: they live outside the component tree, and components subscribe to a slice — so a change re-renders only the components that read that slice. That selector model is the real reason to use one.

A store is state that lives outside React, with a way to subscribe to just the part you care about. The store holds the data; each component picks a slice with a selector and only re-renders when that slice changes — the missing piece in raw Context.
import { create } from 'zustand' const useCart = create((set) => ({ items: [], add: (it) => set((s) => ({ items: [...s.items, it] })), })) // subscribe to ONE slice → re-render only when it changes const count = useCart((s) => s.items.length)
store items · user · theme Countreads items.length Avatarreads user · idle Headerreads theme · idle

Add an item and only Count re-renders — Avatar and Header read other slices and stay idle.

The tooling landscape — client stores

All four solve sharing. They differ in mental model and ceremony. One-line pro, con, and when each fits.

Zustand — a tiny hook-shaped store

One create call returns a hook; no provider, no boilerplate. Selectors are built in. The pragmatic default for most apps in 2026.

Pro
Minimal boilerplate; selector-based subscriptions; works outside React too.
Con
Few conventions — large teams must impose their own structure and discipline.
Choose it when
You want shared client state with the least ceremony — the common case.

Redux Toolkit (RTK) — Redux, minus the pain

The official, modern Redux. Slices generate actions and reducers for you; strong conventions, time-travel DevTools, and a middleware ecosystem.

Pro
Predictable patterns at scale; excellent DevTools; one well-trodden path for big teams.
Con
More ceremony and a larger bundle than Zustand or Jotai for simple needs.
Choose it when
A large team needs enforced structure, audit-friendly logic, and tooling.

Jotai — bottom-up atoms

State is built from tiny atom primitives that compose; derived atoms recompute only when their inputs change. Granular by design, with a React-native feel.

Pro
Fine-grained updates; derived state is trivial; no central reducer to grow.
Con
Many small atoms can be hard to trace; the atomic model is a mental shift.
Choose it when
You have lots of independent, finely-derived pieces of UI state.

Signals — fine-grained reactivity

A signal is a value that notifies its readers directly, skipping component-level diffing. Native in SolidJS and Angular; in React via Preact Signals. The future direction for granular updates.

Pro
Surgical updates — only the exact DOM bindings that read a signal change.
Con
Not idiomatic React yet; mixing with the render model adds cognitive overhead.
Choose it when
You're on a signals-native framework, or chasing very high update throughput.

The honest default: if Context plus a little lifting still works, you don't need any of these yet. When you do, Zustand is the smallest step up for most teams.

04 · Server-state caches 6 min

Server state isn't a store problem —
it's a caching problem.

The moment you fetch, you hold a copy that can be wrong. A server-state library makes that copy honest: it dedupes requests, caches by key, serves instantly while refetching in the background, and gives you a clear way to invalidate. See also Caching & CDNs for the caching fundamentals and API Design for the endpoints behind these keys.

Stale-while-revalidateshow the cached (possibly stale) value immediately, then refetch in the background and update if it changed. The UI feels instant and converges to fresh. It is the core trick behind every tool on this slide.
const { data, isPending, error } = useQuery({ queryKey: ['todos', filter], // identity of this cache entry queryFn: () => fetchTodos(filter), staleTime: 30_000, // trust cache for 30s }) // after a mutation, mark the cache dirty so it refetches queryClient.invalidateQueries({ queryKey: ['todos'] })
componentuseQuery query cachekey ['todos'] server / APIsource of truth 1 · instant (stale) 2 · refetch 3 · fresh 4 · re-render w/ fresh

The cache answers instantly, refetches behind the scenes, then re-renders with fresh data. The query key is the identity of each cache entry.

The tooling landscape — server-state caches

These are caches, not stores. Pick by how much you need and what you're already running.

TanStack Query — the full-featured default

Caching, deduping, background refetch, retries, pagination, optimistic updates, and first-class DevTools. Framework bindings for React, Vue, Svelte, Solid and more.

Pro
Batteries included; mutations and invalidation are first-class; superb DevTools.
Con
More surface area to learn — keys, staleTime, cache lifetimes.
Choose it when
Server state is a core part of the app — the safe default in 2026.

SWR — small and focused

From Vercel, named after stale-while-revalidate. A tinyuseSWR(key, fetcher) covers fetching, caching, and revalidation with very little API.

Pro
Minimal API and bundle; gets reads-with-revalidation right out of the box.
Con
Thinner mutation / cache-management story than TanStack Query.
Choose it when
Mostly reads, you want the lightest tool — great fit on Next.js.

RTK Query — server cache inside Redux

Part of Redux Toolkit. You declare endpoints; it generates hooks and stores the cache in your Redux store, with tag-based invalidation and optional code generation from your API schema.

Pro
Zero extra dependency if you already run Redux; cache lives beside client state.
Con
Tied to Redux; awkward to adopt just for fetching if you aren't already on RTK.
Choose it when
You're already committed to Redux Toolkit and want one consistent stack.

How to choose: already on RTK → RTK Query. Lightweight, mostly reads → SWR. Anything richer → TanStack Query. All three beat a hand-rolled useEffect fetch.

05 · Deriving & normalizing state 4 min

The cheapest state is
the state you don't store.

Every value you store is a value that can drift out of sync. Two habits prevent most bugs: derive anything you can compute from existing state, and keep a single source of truth for each entity instead of scattering copies.

Derived statea value you compute from existing state during render, rather than storing separately. A total, a count, a filtered list. If it can be calculated from something you already have, calculating it can't go stale — but a stored copy can.
two sources of truth that drift
const [items, setItems] = useState([]) const [total, setTotal] = useState(0) // duplicated truth function add(it) { setItems([...items, it]) setTotal(total + it.price) // forget this once → out of sync }
one source, derive the rest
const [items, setItems] = useState([]) const total = items.reduce((s, it) => s + it.price, 0) // expensive? memoize — still derived, just cached const total = useMemo( () => heavySum(items), [items], )

Store items; everything else is a function of it. Reach for useMemo only when the computation is genuinely expensive — correctness first, then performance.

Normalize shared entities

When the same entity appears in many places, don't embed copies of it. Store each entity once, keyed by id, and reference it. Update one record and every view updates — there are no stale duplicates to chase. It's how a database thinks, and why tools like Redux ship an entity adapter.

  • One home per entity, keyed by id.
  • Lists hold ids, not nested copies.
  • Server-state libraries often hide this for you — but the principle still guides your keys.
// duplicated — the author is copied into every post { posts: [ { id: 1, author: { id: 9, name: 'Ada' } }, { id: 2, author: { id: 9, name: 'Ada' } }, ] } // normalized — one home per entity, referenced by id { posts: { 1: { id: 1, authorId: 9 }, 2: { id: 2, authorId: 9 } }, users: { 9: { id: 9, name: 'Ada' } } }

Rename Ada once in users[9] and both posts reflect it — no duplicate to forget.

items[]source of truth total = Σ price count = length filtered view

One stored array; total, count, and filtered views are all computed on render — nothing to keep in sync.

The rule of thumb

  • Ask of every useState: can I compute this instead? If yes, don't store it.
  • Two pieces of state that must always agree are a bug waiting to happen — collapse them to one.
  • Duplicated entities are the same trap at data scale — normalize.
06 · URL & form state 4 min

The state you forget you have:
the URL and your forms.

Before adding a store for filters or a wizard, check whether the state belongs in the URL or stays inside a form. Both are real state containers you already have — and using them deletes whole categories of bugs.

URL statestate encoded in the path and query string, so it's shareable, bookmarkable, and survives a refresh. Filters, sort order, the current page, the open tab, a search term: if a user would expect a link to reproduce the view, it belongs in the URL.
// Next.js App Router — read view state from the URL const params = useSearchParams() const sort = params.get('sort') ?? 'new' // write it back — shareable, refresh-proof, back-button-aware router.push('?sort=' + next) // libraries like nuqs add typed, ergonomic URL state
/products?category=tents · sort=price · page=2 shareable link survives refresh back / forward

Put filters and pagination in the query string and the link is the state — no store, and every view is shareable.

Form state stays in the form

A form draft is ephemeral, local client state — it rarely belongs in a global store. The real choice is controlled (React owns each keystroke) vs uncontrolled (the DOM owns the value, React reads it on submit). Uncontrolled forms re-render far less, which is why form libraries lean that way.

  • Controlled — every keystroke is state. Needed for live validation or dependent fields; costs re-renders.
  • Uncontrolled — the DOM holds the value; read it on submit. Cheaper, simpler for plain forms.
  • Reach for React Hook Form or TanStack Form once forms get real — validation, arrays, async checks.
// react-hook-form — uncontrolled by default, few re-renders const { register, handleSubmit } = useForm() <form onSubmit={handleSubmit(save)}> <input {...register('email')} /> <input {...register('password')} /> </form> // React reads values on submit, not on every keystroke

The form library owns the draft. It reaches a store or a server only when the user hits submit.

07 · Choosing a tool + recap 4 min

One question answers most of it:
where does this state live?

There's no single "state management library" — there are categories, and most apps use a couple together. Route each piece of state by ownership and the choice mostly makes itself.

where does it live? on a server?→ Query / SWR in the URL?→ search params one subtree?→ useState app-wide client state?→ Zustand / Jotai / RTK

Server, URL, and a single subtree absorb most state. A global client store is the last resort, not the first.

Read the flow

  • Lives on a server? It's a cache → TanStack Query or SWR. Don't put it in a store.
  • Should a link reproduce it? URL search params.
  • Used by one component or subtree? useState/useReducer, lift only as far as needed.
  • Genuinely app-wide client state? Only now a store — Zustand by default.

Redux Toolkit is still excellent for large teams that want enforced structure and audit-friendly logic. But a huge share of historical Redux was really server state — and that job now belongs to a query cache. Separate the two first; many apps find they need very little global client state at all.

Five rules to walk out with

1Split client from server state. It's the decision every other one hangs off.
2Server state is a cache — use a query library, never useState + useEffect.
3Start local. useState → lift → Context → a store with selectors — only as the need appears.
4Derive, don't duplicate. One source of truth; normalize shared entities by id.
5Use the containers you already have — the URL for shareable view state, the form for drafts.
Knowledge check

Did it stick?

Five quick questions on the client/server split, Context, stores, caching, and where state belongs — instant feedback, no sign-in.

Rate this deck
be the first

Navigate with ← → or scroll · back to library