---
title: "08 — State Management — Context, Zustand, Jotai, and When to Reach Beyond useState"
uid: state-management
tags: ["zustand", "react", "context", "jotai", "roadmap:react", "mobx", "state-management"]
excerpt: "A ladder from useState up through Context to a dedicated store — the right rung depends on how widely the state is shared and how often it changes."
date: 2026-08-13T03:27:45+0000
source: https://www.aveshina.my.id/en/blog/state-management
---

A wall of library names was how I greeted state management until I saw the ladder underneath. The model that organized it: **there's a ladder, and the right rung depends on two questions — how widely the state is shared, and how often it changes.** [1] useState and useReducer live on one component. Context shares values across a tree but re-renders every consumer on change. When state is both widely shared and frequently changing, a dedicated store — Zustand, Jotai, or MobX — earns its keep.

## The shared-state problem

Local state with useState is fine when one component owns a value. The trouble starts the moment two siblings need the same value, or a deeply-nested component needs something from the top of the tree. The textbook answer is **lift the state up** to the nearest common ancestor and pass it down through props. That works for a couple of levels; past that, I'm threading props through components that don't care about the value just to deliver it where it's needed — the "prop-drilling" tax.

State management is the discipline of solving that sharing problem at scale, and the libraries on the roadmap sit at different points on the complexity curve.

## Context: dependency injection, not a store

React's built-in **Context** is the first rung past prop-drilling [2]. A Provider at the top of the tree declares a value; any descendant can useContext to read it without the intermediate components having to know about it. The canonical cases are values that are shared widely and change rarely: theme, locale, the current authenticated user.

The critical caveat, and the one that bit me: **Context is not a state management library.** [2] When the Provider's value changes, every component that consumes that context re-renders — even ones reading a different field. For slowly-changing values that's fine. For a counter that ticks every second, or a list that updates on every keystroke, every consumer re-renders on every change, and on a large tree that's a performance cliff. That's the signal to move up the ladder.

```figure
<svg viewBox="0 0 740 320" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="A ladder of state management. Bottom rung: useState/useReducer for one component. Middle rung: Context for shared, rarely-changing values. Top rung: Zustand/Jotai/MobX stores for shared, frequently-changing values. Each rung labeled with when to use it.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <!-- ladder rails -->
    <line x1="180" y1="40" x2="180" y2="280" stroke="#475569" stroke-width="3"/>
    <line x1="560" y1="40" x2="560" y2="280" stroke="#475569" stroke-width="3"/>

    <!-- rung 3 (top) -->
    <rect x="160" y="50" width="420" height="60" rx="8" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="370" y="74" font-size="13" font-weight="700" fill="#500724" text-anchor="middle">Zustand · Jotai · MobX — external store</text>
    <text x="370" y="94" font-size="10.5" fill="#500724" text-anchor="middle">shared, frequently-changing state · components subscribe to slices</text>

    <!-- rung 2 (middle) -->
    <rect x="180" y="130" width="380" height="60" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="370" y="154" font-size="13" font-weight="700" fill="#1e1b4b" text-anchor="middle">Context — built-in</text>
    <text x="370" y="174" font-size="10.5" fill="#1e1b4b" text-anchor="middle">shared, rarely-changing values (theme, locale, auth)</text>

    <!-- rung 1 (bottom) -->
    <rect x="200" y="210" width="340" height="60" rx="8" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="370" y="234" font-size="13" font-weight="700" fill="#422006" text-anchor="middle">useState · useReducer — local</text>
    <text x="370" y="254" font-size="10.5" fill="#422006" text-anchor="middle">state owned by one component</text>

    <!-- y axis labels -->
    <text x="80" y="84" font-size="10" fill="#9ca3af" text-anchor="middle" font-style="italic">shared +</text>
    <text x="80" y="98" font-size="10" fill="#9ca3af" text-anchor="middle" font-style="italic">changes often</text>
    <text x="80" y="164" font-size="10" fill="#9ca3af" text-anchor="middle" font-style="italic">shared +</text>
    <text x="80" y="178" font-size="10" fill="#9ca3af" text-anchor="middle" font-style="italic">changes rarely</text>
    <text x="80" y="244" font-size="10" fill="#9ca3af" text-anchor="middle" font-style="italic">one owner</text>

    <text x="660" y="160" font-size="11" fill="#64748b" text-anchor="middle" font-style="italic" transform="rotate(90 660 160)">escalate when prop-drilling or Context re-renders hurt</text>
  </g>
</svg>
```

## Zustand: small, hooks-based, my default store

When state genuinely needs to leave a single component, **Zustand** is the store I reach for first [3]. It's deliberately tiny — a store is created with one create call, and components subscribe with a hook. Two properties earned it the default slot:

- **No boilerplate, no opinion.** There's no action/reducer ceremony like Redux, no provider wrapper required. I write the store as one function and call it from anywhere.
- **Fine-grained subscriptions.** A component selects just the slice it needs; Zustand only re-renders that component when that specific slice changes. That fixes the Context re-render problem by design.

```
const useStore = create((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
}));

// a component subscribes to only count
const count = useStore((s) => s.count);
```

Zustand sits in the "small to medium app" sweet spot — when I want a global store without Redux's ceremony, it's the answer.

## Jotai: atomic state, signals-like DX

**Jotai** takes an **atomic** approach: state is broken into atoms (small units), and components subscribe to the atoms they read [4]. Because subscriptions are per-atom, renders are optimized automatically — a component only re-renders when one of its specific atoms changes. Atoms compose, so complex state is built by combining simpler ones, and the experience is close to signals — little wires connecting one piece of data to one spot on the screen — while staying declarative.

```
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);

const [count, setCount] = useAtom(countAtom);
```

Jotai is the choice when state is naturally a graph of derived values, or when the memoization pain of useMemo/selector functions becomes the bottleneck. It's a touch more conceptually demanding than Zustand, so I reach for it specifically when derived-state composition matters.

## MobX: observable, mutable, opinionated

**MobX** is the elder statesman of this group, built on **observable** values — values that announce when they change — and automatic dependency tracking [5]. State is mutable — I write user.name = 'Ave' directly, and MobX's reactivity system re-renders the components reading user.name automatically. The tradeoff is a heavier conceptual model (observables, actions, reactions) and more "magic" than hooks-based stores. It's powerful and ergonomic once internalized, but I treat it as a team-scale choice rather than a starter store.

## Redux: not on the React roadmap, but worth naming

The roadmap's React state-management section doesn't list Redux directly (it shows up in the broader frontend roadmap). I note it only because it's the historical default: a single store, actions, and reducers, with a lot of boilerplate. Redux Toolkit cut that boilerplate, and RTK Query (covered in the data-fetching notes) extended it into server-state caching. For greenfield React today I'd start with Zustand unless the team already lives in Redux.

## How I use this

My state-placement checklist, in order:

- **One component owns it?** → useState / useReducer. Don't over-engineer.
- **Shared across the tree, but rarely changing (theme, locale, auth session)?** → Context. It's built-in and fine here.
- **Shared across the tree AND frequently changing (cart, real-time data, form state across pages)?** → a dedicated store, defaulting to Zustand for its tiny API and selector-based re-renders. Jotai if the state is naturally a graph of derived atoms.
- **Server data (API responses, mutations, caching)?** → that's a different problem entirely, handled by data-fetching libraries (TanStack Query, SWR), not by a state store. Mixing server cache into a Zustand store is a common mistake; the data-fetching notes cover why.

The discipline is matching the rung to the actual sharing and change-frequency profile, not reaching for the most powerful tool by default.

## References

[1] R. Wieruch, "Overview of state in React," robinwieruch.de, 2023. [Online]. Available: [https://www.robinwieruch.de/react-state/](https://www.robinwieruch.de/react-state/)

[2] React team, "Passing data deeply with context," react.dev, 2024. [Online]. Available: [https://react.dev/learn/passing-data-deeply-with-context](https://react.dev/learn/passing-data-deeply-with-context)

[3] pmndrs, "Zustand — getting started," docs.pmnd.rs, 2024. [Online]. Available: [https://docs.pmnd.rs/zustand/getting-started/introduction](https://docs.pmnd.rs/zustand/getting-started/introduction)

[4] Jotai team, "Jotai — atomic state management," jotai.org, 2024. [Online]. Available: [https://jotai.org/](https://jotai.org/)

[5] MobX team, "MobX — simple, scalable state management," mobx.js.org, 2024. [Online]. Available: [https://mobx.js.org/](https://mobx.js.org/)

[6] T. Kнoup, "Working with Zustand," tkdodo.eu, 2023. [Online]. Available: [https://tkdodo.eu/blog/working-with-zustand](https://tkdodo.eu/blog/working-with-zustand)

```quiz
Q: Context's main weakness as a state-management solution is…
- it cannot share values across a tree
- every consumer re-renders when the provider value changes, even ones reading other fields
- it requires a third-party library
correct: 1
explain: Context is dependency injection, not a store. On frequently-changing values it re-renders every consumer, which is why a selector-based store is the better tool for high-frequency shared state.

Q: Zustand avoids the Context re-render problem by…
- only re-rendering components whose selected slice changed
- batching all updates into a single render of the whole tree
- using class components internally
correct: 0
explain: Components subscribe with a selector (useStore(s => s.field)) and only re-render when that specific slice changes, giving fine-grained updates without the whole-tree re-render.

Q: Jotai's defining trait is…
- an atomic model where state is composed from small atoms, with automatic per-atom re-render optimization
- a single global mutable store
- a Redux-style action/reducer layer
correct: 0
explain: Jotai breaks state into atoms; components subscribe to the atoms they read, and derived atoms compose. Subscriptions are per-atom, so re-renders are optimized automatically.

Q: Server data (API responses, mutation results) is best handled by…
- copying it into a Zustand store manually
- a dedicated data-fetching library (TanStack Query, SWR) with its own cache, not a state store
- useState in every component that needs it
correct: 1
explain: Server state has different needs (caching, dedup, revalidation, mutation) than client state. Libraries like TanStack Query own that; mixing it into a state store is a common mistake.

Q: The state-placement ladder goes (from simplest to heaviest):
- useState → Context → dedicated store (Zustand/Jotai/MobX)
- Redux → MobX → Context → useState
- Context → useState → Redux
correct: 0
explain: Start with local useState/useReducer; add Context for shared rarely-changing values; escalate to a selector-based store when shared state changes frequently.
```
