Module 6: Global State and App Architecture
Zustand: Small Global Stores
Creating and consuming a store, selecting slices to avoid extra re-renders, persistence, using state outside React, and comparing it with Context and Redux.
Zustand (German for "state") does the job people usually want from Redux in a fraction of the code. A store is a plain function call. You read it with a hook. There's no provider, no reducers, no actions to dispatch. It's a good fit when you've outgrown context but don't want ceremony.
npm install zustand
A store in one file#
// src/stores/useCartStore.js
import { create } from "zustand";
export const useCartStore = create((set, get) => ({
items: [],
addItem: (product) =>
set((state) => {
const existing = state.items.find((i) => i.id === product.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return { items: [...state.items, { ...product, qty: 1 }] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}));
create takes a function that receives set and get and returns your initial state together with the actions that change it. It gives you back a hook.
set merges the object you pass into the existing state (shallowly, one level). You don't need to spread the rest of the state yourself, which is a little different from useState. You still must not mutate nested values, since the merge is shallow.
Using it#
import { useCartStore } from "./stores/useCartStore.js";
function CartBadge() {
const count = useCartStore((state) => state.items.length);
return <span>Cart ({count})</span>;
}
function AddButton({ product }) {
const addItem = useCartStore((state) => state.addItem);
return <button onClick={() => addItem(product)}>Add</button>;
}
No provider. Import the hook and use it anywhere. The function you pass is a selector, and it matters.
Selectors decide what re-renders#
A component re-renders only when the value its selector returns changes (compared with Object.is).
const count = useCartStore((s) => s.items.length); // re-renders only when length changes
const store = useCartStore(); // subscribes to everything, re-renders on any change
Calling the hook with no selector works, and it's the easiest way to accidentally make every component re-render on every update. Get in the habit of always selecting.
This is the big practical advantage over context. With context, any change to the value re-renders every consumer. With a selector, each component subscribes to just the piece it reads.
┌─────────── store: one plain object plus a list of subscribers ───────────┐
│ { items: [a, b], addItem: fn } │
└───────────────┬───────────────────────────────────┬──────────────────────┘
addItem(c) → set({ items: [a,b,c] }) │
│ store tells every subscriber │
▼ ▼
CartBadge: s => s.items.length AddButton: s => s.addItem
2 → 3, value changed, re-render same function as before, skip
Selecting multiple values#
This is a classic trap in current Zustand. If your selector returns a new array or object each time, it never equals the previous one, so the component re-renders constantly. In version 5, that even throws an infinite-loop error.
// New object every time → breaks
const { items, total } = useCartStore((s) => ({ items: s.items, total: s.total() }));
// Option 1: select each value separately
const items = useCartStore((s) => s.items);
const addItem = useCartStore((s) => s.addItem);
// Option 2: useShallow compares the object's contents instead of identity
import { useShallow } from "zustand/react/shallow";
const { items, clear } = useCartStore(useShallow((s) => ({ items: s.items, clear: s.clear })));
Option 1 is plainer and is what I'd reach for first.
Persistence#
The persist middleware saves the store to localStorage and restores it on load.
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useCartStore = create(
persist(
(set) => ({
items: [],
addItem: (p) => set((s) => ({ items: [...s.items, p] })),
}),
{
name: "cart-storage", // storage key
partialize: (state) => ({ items: state.items }), // only save data, not functions
}
)
);
If your state shape changes between releases, use the version and migrate options. Otherwise old saved data can crash new code.
Using the store outside React#
Because a store is a plain object under the hood, you can read and change it from anywhere.
// api.js
import { useCartStore } from "./stores/useCartStore.js";
export async function checkout() {
const items = useCartStore.getState().items; // read
await fetch("/api/checkout", { method: "POST", body: JSON.stringify(items) });
useCartStore.getState().clear(); // call an action
}
That's handy for API interceptors, route guards and tests. It's something Context can't do.
Resetting and slices of logic#
A store with a hundred fields gets unwieldy. Split by concern: useAuthStore, useCartStore, useUIStore. It's fine to have several small stores. If one needs another, call useOtherStore.getState() inside an action.
Zustand, Context or Redux Toolkit#
- Context is built in and ideal for small, rarely changing values (theme, locale, current user). No selectors, so all consumers update together.
- Zustand is tiny, needs no provider, has selector-based subscriptions, and works outside React. Excellent default for client state in small to medium apps.
- Redux Toolkit adds structure, strong devtools and conventions. Better when many people work on the code and the extra discipline pays off.
None of them are for server data. Use TanStack Query for that.
Where people get burned#
- Calling the hook without a selector and re-rendering on everything.
- Returning a new object or array from a selector, causing re-renders or loops.
- Mutating nested state (
state.items[0].qty++) insideset. Because the merge is shallow, nested changes need new references. - Persisting functions, or forgetting to bump
versionafter a state shape change. - Putting server data in it and writing refetch logic by hand.
- Creating one giant store that everything depends on.
Practice#
- Rebuild the cart from the previous lesson in Zustand. Compare the line counts.
- Add
persistand check that the cart survives a page refresh. Open DevTools → Application → Local Storage and look at what's saved. - Log renders in two components, one that selects
items.lengthand one that selectsitems. Add an item and compare which re-render. - Write a plain JavaScript function (not a component) that clears the cart when a 401 response comes back, using
getState().