5.3 — State architecture¶
Module 5 · Lesson 3 · 🔴 Advanced · ~40 min
What you'll learn¶
- Where state should live, and the performance consequence of getting it wrong
- Context splitting, and why one big context is a render storm generator
- External stores with selectors (
useSyncExternalStore, Zustand) for high‑frequency state - URL as state: the option that makes filters cacheable and fast
This lesson fixes Aurora's structural problem #3.
The placement rule¶
State should live at the lowest common ancestor of the components that read it — and no higher.
Every level you lift state above that point re‑renders a subtree that doesn't care.
// ❌ Modal open state at the page level: opening the size guide
// re-renders the entire product page including the 12-image gallery
function ProductPage({ product }: Props) {
const [sizeGuideOpen, setSizeGuideOpen] = useState(false);
return (
<>
<ProductGallery images={product.images} />
<ProductInfo product={product} />
<SizeGuideTrigger onOpen={() => setSizeGuideOpen(true)} />
{sizeGuideOpen && <SizeGuideModal onClose={() => setSizeGuideOpen(false)} />}
</>
);
}
// ✅ State inside the component that owns it. Opening the modal
// re-renders exactly one component.
function SizeGuide({ categoryId }: { categoryId: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Size guide</button>
{open && <SizeGuideModal categoryId={categoryId} onClose={() => setOpen(false)} />}
</>
);
}
Audit heuristic: for every useState in a component with more than ~3 children, ask "who reads
this?" If the answer is one child, push it down.
Context: the render‑storm generator¶
Context is a dependency‑injection mechanism, not a state manager. Every consumer re‑renders when the context value identity changes — regardless of which field they read.
// ❌ Aurora's cart context: one object, five concerns
const CartContext = createContext<{
items: CartItem[]; // changes on add/remove
isOpen: boolean; // changes on drawer toggle
isLoading: boolean; // changes on every mutation
subtotal: number; // changes with items
addToCart: (id: string) => void; // stable
}>(null!);
A product card calls useContext(CartContext) for addToCart only. When the user opens the
drawer, isOpen changes → the value object changes → all 48 product cards re‑render. That's
Aurora's 410 ms PLP INP.
Fix 1 — split by change frequency¶
// Stable actions: never changes → consumers never re-render from this
const CartActionsContext = createContext<{
addToCart: (id: string, qty?: number) => Promise<void>;
removeFromCart: (lineId: string) => Promise<void>;
updateQuantity: (lineId: string, qty: number) => Promise<void>;
}>(null!);
// Cart contents: changes on mutation
const CartItemsContext = createContext<{ items: CartItem[]; subtotal: number }>(null!);
// UI state: changes on drawer toggle
const CartUiContext = createContext<{ isOpen: boolean; setOpen: (b: boolean) => void }>(null!);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const [isOpen, setOpen] = useState(false);
// Actions never change identity — the ref pattern keeps them stable
// even though they read current state.
const itemsRef = useRef(items);
itemsRef.current = items;
const actions = useMemo(
() => ({
addToCart: async (id: string, qty = 1) => { /* uses itemsRef.current */ },
removeFromCart: async (lineId: string) => { /* … */ },
updateQuantity: async (lineId: string, qty: number) => { /* … */ },
}),
[], // ← empty deps: identity is permanent
);
const itemsValue = useMemo(
() => ({ items, subtotal: items.reduce((s, i) => s + i.price * i.quantity, 0) }),
[items],
);
const uiValue = useMemo(() => ({ isOpen, setOpen }), [isOpen]);
return (
<CartActionsContext.Provider value={actions}>
<CartItemsContext.Provider value={itemsValue}>
<CartUiContext.Provider value={uiValue}>{children}</CartUiContext.Provider>
</CartItemsContext.Provider>
</CartActionsContext.Provider>
);
}
// Product cards consume ONLY actions → they never re-render from cart changes
export const useCartActions = () => useContext(CartActionsContext);
Aurora's PLP INP after this change alone: 410 ms → 240 ms.
Fix 2 — external store with selectors (better for high‑frequency state)¶
Context can't do fine‑grained subscription: a consumer re‑renders when the value changes, even if the field it reads didn't. External stores can.
// lib/cart-store.ts
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
type CartState = {
items: CartItem[];
isOpen: boolean;
isLoading: boolean;
addToCart: (id: string, qty?: number) => Promise<void>;
removeFromCart: (lineId: string) => Promise<void>;
setOpen: (open: boolean) => void;
};
export const useCartStore = create<CartState>((set, get) => ({
items: [],
isOpen: false,
isLoading: false,
addToCart: async (id, qty = 1) => {
// Optimistic update first — the UI responds immediately
const optimisticLine = { id: `tmp-${id}`, productId: id, quantity: qty, price: 0 };
set((s) => ({ items: [...s.items, optimisticLine], isLoading: true }));
try {
const server = await api.addToCart(id, qty);
set({ items: server.items, isLoading: false });
} catch {
set((s) => ({ items: s.items.filter((i) => i.id !== optimisticLine.id), isLoading: false }));
toast.error('Could not add to bag');
}
},
removeFromCart: async (lineId) => { /* … */ },
setOpen: (isOpen) => set({ isOpen }),
}));
// Selectors: each component subscribes to exactly what it reads
export const useCartCount = () =>
useCartStore((s) => s.items.reduce((n, i) => n + i.quantity, 0));
export const useCartOpen = () => useCartStore((s) => s.isOpen);
// Actions are stable references on the store — zero re-render subscriptions
export const useAddToCart = () => useCartStore((s) => s.addToCart);
// A product card subscribes to a stable function → NEVER re-renders from cart state
function AddToCartButton({ productId }: { productId: string }) {
const addToCart = useAddToCart();
return <button onClick={() => addToCart(productId)}>Add to bag</button>;
}
// The header badge subscribes only to the count → re-renders only when the count changes
function CartBadge() {
const count = useCartCount();
return <span className="tabular-nums">{count || ''}</span>;
}
If you don't want a dependency, useSyncExternalStore is built into React:
// lib/store.ts — a ~30 line store with selector subscriptions
import { useSyncExternalStore } from 'react';
export function createStore<T>(initial: T) {
let state = initial;
const listeners = new Set<() => void>();
const getState = () => state;
const setState = (partial: Partial<T> | ((s: T) => Partial<T>)) => {
const next = typeof partial === 'function' ? partial(state) : partial;
state = { ...state, ...next };
listeners.forEach((l) => l());
};
const subscribe = (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
};
function useStore<S>(selector: (s: T) => S): S {
return useSyncExternalStore(
subscribe,
() => selector(getState()),
() => selector(initial), // server snapshot — must be stable
);
}
return { getState, setState, subscribe, useStore };
}
getServerSnapshotmust return a stable value, or you'll get an infinite loop in SSR. Return the same object reference, not a fresh one.
Choosing a state location¶
| State | Where | Why |
|---|---|---|
| Modal open/closed | Local useState in the trigger |
Nobody else reads it |
| Form field values | Local, or uncontrolled inputs | High frequency, single consumer |
| Selected variant | Local to the PDP buy box | Only the buy box and gallery read it |
| Cart contents | External store + server | Read across the app, mutated rarely |
| Filters/sort/page | URL | Shareable, back‑button correct, server‑renderable, cacheable |
| Search query | URL (results) + local (input) | Same, but keep typing local |
| Auth/session | Server (cookie) + minimal client mirror | Security and correctness |
| Theme/locale | Cookie + server render | Avoids flash and hydration mismatch |
| Recently viewed | localStorage + client store |
Per‑device, non‑critical |
| Server data | Server Components, or a data library | Not "state" — it's cache |
URL as state: the underrated win¶
Putting filter/sort/pagination in the URL is a performance decision as much as a UX one.
// app/c/[slug]/page.tsx — Server Component reads filters from the URL
export default async function CategoryPage({
params, searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const filters = parseFilters(await searchParams);
// Filtering happens on the server against cached data
const [category, results] = await Promise.all([
getCategory(slug),
searchProducts({ category: slug, ...filters }),
]);
return <CategoryView category={category} results={results} filters={filters} />;
}
// components/filter-checkbox.tsx — updates the URL, wrapped in a transition
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
export function FilterCheckbox({ facet, value, label, count }: Props) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const checked = searchParams.getAll(facet).includes(value);
const toggle = () => {
const params = new URLSearchParams(searchParams);
const current = params.getAll(facet);
params.delete(facet);
const next = checked ? current.filter((v) => v !== value) : [...current, value];
next.forEach((v) => params.append(facet, v));
params.delete('page'); // filter change resets pagination
// The checkbox flips instantly; the results update as a transition
startTransition(() => {
router.push(`${pathname}?${params}`, { scroll: false });
});
};
return (
<label className={isPending ? 'opacity-60' : ''}>
<input type="checkbox" checked={checked} onChange={toggle} />
{label} <span className="text-neutral-500">({count})</span>
</label>
);
}
What you gain:
| Benefit | Impact |
|---|---|
| Filtering happens on the server | No 48‑card client re‑render |
| Results are shareable and bookmarkable | Real UX and SEO value |
| Back button works correctly | Fewer support tickets, better bfcache behavior |
| Popular filter combinations become cacheable | TTFB win |
| Client bundle shrinks (no client filter engine) | Fewer KB |
startTransition keeps the checkbox responsive |
INP win |
What you must handle: a network round trip per filter change. Mitigate with
startTransition (so the UI never blocks), an optimistic checkbox state, and prefetching common
next‑filters. On a fast server this is ~120 ms and feels better than a 400 ms client re‑render,
because the input responds instantly either way.
Server state is not client state¶
The most common over‑engineering in commerce codebases: putting server data into client state.
// ❌ Server data copied into client state, with all the sync problems that implies
'use client';
function ProductPage({ productId }: Props) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/products/${productId}`)
.then((r) => r.json())
.then((p) => { setProduct(p); setLoading(false); });
}, [productId]);
if (loading) return <Skeleton />;
return /* … */;
}
// ✅ Server Component: no client state, no loading flag, no waterfall, no JS
async function ProductPage({ params }: Props) {
const { slug } = await params;
const product = await getProduct(slug);
return /* … */;
}
When you genuinely need client‑side server data (polling stock, live cart sync), use a data library
with caching and deduplication rather than hand‑rolled useState+useEffect:
'use client';
import useSWR from 'swr';
export function LiveStock({ productId, initialStock }: Props) {
const { data } = useSWR(`/api/stock/${productId}`, fetcher, {
fallbackData: initialStock, // server-rendered value; no loading flash
refreshInterval: 30_000,
revalidateOnFocus: true,
dedupingInterval: 10_000, // multiple components share one request
});
return <StockIndicator stock={data} />;
}
Aurora's state refactor¶
| Change | INP (PLP p75) |
|---|---|
| Baseline: monolithic cart context, client‑side filters | 410 ms |
| Split cart context by change frequency | 240 ms |
Move filters to URL + startTransition |
165 ms |
| Cart → Zustand with selectors | 150 ms |
| Virtualize the grid (5.4) | 118 ms |
No component was rewritten for "performance" in the micro sense. Every gain came from moving state to the right place.
Common mistakes¶
| Mistake | Cost |
|---|---|
| One context for everything | Every consumer re‑renders on any change |
| Unmemoized context value | Same, but worse — even unchanged data re‑renders |
| State lifted higher than needed | Subtree re‑renders for nothing |
| Filters in client state | Client re‑render storm; unshareable URLs; no caching |
Server data in useState |
Waterfalls, loading flashes, sync bugs |
| Global store for local UI state | Coupling and unnecessary subscriptions |
Unstable getServerSnapshot |
Infinite loop in SSR |
| Storing derived values in state | Double renders, stale data |
Lab 5.3 — State audit¶
- Map your contexts:
rg -n "createContext" app components lib. For each: what fields, how often does each change, and how many consumers? - Find the storm: in the React Profiler, perform a cart action on a PLP. How many components re‑render? Anything over ~10 for a cart badge update is a context problem.
- Split your highest‑traffic context by change frequency. Measure the commit duration before and after.
- Move filters to the URL on one category page. Measure INP and the client bundle delta.
- Find lifted state: for every
useStatein a component with many children, identify who reads it and push it down where possible. - Find server data in client state:
rg -n "useEffect" app components | rg -i "fetch|axios"— each hit is a candidate for a Server Component.
Checklist¶
- No monolithic context; contexts split by change frequency
- All context values memoized
- Actions have permanently stable identity
- High‑frequency shared state uses an external store with selectors
- Filters, sort, and pagination live in the URL
- URL updates wrapped in
startTransition - Server data fetched by Server Components, not
useEffect - Local UI state is local
- No derived values stored in state