Skip to content

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 };
}

getServerSnapshot must 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

  1. Map your contexts: rg -n "createContext" app components lib. For each: what fields, how often does each change, and how many consumers?
  2. 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.
  3. Split your highest‑traffic context by change frequency. Measure the commit duration before and after.
  4. Move filters to the URL on one category page. Measure INP and the client bundle delta.
  5. Find lifted state: for every useState in a component with many children, identify who reads it and push it down where possible.
  6. 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

Next: 5.4 Long lists & virtualization