Day 27: Atomic & Unidirectional State: Zustand vs Jotai
Understand the two dominant lightweight state models — single-store-with-selectors (Zustand) vs atomic composition (Jotai) — and when to reach for each over Redux.
Study
Concepts
Zustand: a minimal single store, subscription-based
Zustand keeps one plain store object (similar shape to Redux) but skips the ceremony: no actions, no reducers, no Provider required — components call a hook (`useStore(selector)`) that subscribes ONLY to the slice returned by the selector function, using reference-equality checks to skip re-renders when that specific slice has not changed. This selector-based subscription model is Zustand's main performance advantage over naively consuming a full Context value (Day 25's problem).
State updates happen by calling `set()` directly from anywhere (no dispatch required), which trades some of Redux's traceability for far less boilerplate — a good fit for small-to-medium apps or isolated feature state that does not need the full action-log/time-travel-debugging machinery.
Jotai: state as a graph of small atoms
Jotai models state as many small, independent "atoms" (`atom(initialValue)`) rather than one big store object. Components subscribe to individual atoms via `useAtom(atomInstance)`, so a component re-renders ONLY when the specific atom(s) it reads change — there is no single store object to accidentally over-subscribe to. Derived atoms (`atom((get) => get(atomA) + get(atomB))`) compose other atoms reactively, similar to a spreadsheet's formula cells, and Jotai tracks these dependencies automatically.
This atomic model shines for state that is naturally per-instance or deeply composable (e.g. many independent form fields, or widgets on a dashboard each with their own settings) where a single shared store would force artificial centralization.
See It
Visualizations
Visualization
Zustand vs Jotai vs Redux Toolkit
| Zustand | Jotai | |
|---|---|---|
| Mental model | One store + selectors | Many small atoms + derived atoms |
| Boilerplate | Minimal — no actions/reducers | Minimal — define atoms directly |
| Re-render granularity | Per selected slice | Per subscribed atom |
| Great fit for | App-wide state, feature stores | Composable, per-instance state (forms, widgets) |
| DevTools/time-travel | Optional middleware | Optional middleware |
| vs Redux Toolkit | Far less ceremony, less built-in structure/traceability | Fundamentally different mental model, not a drop-in replacement |
Build It
Code Examples
A Zustand store with selector-based subscriptions
import { create } from 'zustand';
const useCartStore = create((set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
total: () => get().items.reduce((sum, i) => sum + i.price, 0),
}));
// Subscribes ONLY to 'items' — unrelated store fields changing won't re-render this
function CartBadge() {
const itemCount = useCartStore((state) => state.items.length);
return <span>{itemCount}</span>;
}
function AddToCartButton({ product }) {
const addItem = useCartStore((state) => state.addItem); // stable function ref
return <button onClick={() => addItem(product)}>Add</button>;
}Jotai atoms with a derived (computed) atom
import { atom, useAtom } from 'jotai';
const cartItemsAtom = atom([]);
// Derived atom: recomputes automatically when cartItemsAtom changes
const cartTotalAtom = atom((get) =>
get(cartItemsAtom).reduce((sum, item) => sum + item.price, 0)
);
function CartTotal() {
const [total] = useAtom(cartTotalAtom); // only re-renders when the TOTAL changes
return <span>${total}</span>;
}
function AddToCartButton({ product }) {
const [, setItems] = useAtom(cartItemsAtom);
return (
<button onClick={() => setItems((prev) => [...prev, product])}>
Add
</button>
);
}Remember
Key Takeaways
- Zustand: one store, selector-based subscriptions, minimal API — a lighter-weight Redux alternative.
- Jotai: many small atoms + derived atoms — a bottom-up, composable model closer to fine-grained reactivity.
- Both avoid Context's "any change re-renders every consumer" problem by subscribing at a granular level.
- Neither has Redux's built-in action log/time-travel debugging by default — add middleware if you need that traceability.
- Pick based on shape of the problem: app-wide store → Zustand/Redux; many independent composable pieces of state → Jotai.
Do It
Practice
- 1Rebuild the cart example from Day 26 using Zustand, and confirm CartBadge does not re-render when an unrelated store field changes.
- 2Rebuild the same cart using Jotai atoms with a derived cartTotalAtom, and compare the code size/ergonomics to the Redux version.
- 3Add zustand/middleware devtools to your store and inspect state changes in Redux DevTools.