From 2f4fdb0b97576d4b4bc2a65aa95a3f122a61bf67 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Thu, 23 Jul 2026 17:36:23 +0000 Subject: [PATCH] feat: Add client state patterns for context vs store --- README.md | 16 +++- app/page.tsx | 6 ++ app/state-management/demo.tsx | 155 ++++++++++++++++++++++++++++++++++ app/state-management/page.tsx | 27 ++++++ src/state/cart-context.tsx | 31 +++++++ src/state/cart.ts | 68 +++++++++++++++ src/state/create-store.ts | 73 ++++++++++++++++ test/state.test.tsx | 121 ++++++++++++++++++++++++++ 8 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 app/state-management/demo.tsx create mode 100644 app/state-management/page.tsx create mode 100644 src/state/cart-context.tsx create mode 100644 src/state/cart.ts create mode 100644 src/state/create-store.ts create mode 100644 test/state.test.tsx diff --git a/README.md b/README.md index 7dbf139..6f38fb1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ Front-end work has changed a lot since the SPA-everything era: rendering moved b - `useActionState` and `useFormStatus` for form result, error, and pending states - Request-scoped memoization for fetch deduplication (the `React.cache()` pattern) - Out-of-order streaming: boundaries flush by resolution time, not tree order +- Pure reducers and discriminated-union actions for client state transitions +- React Context with split state/dispatch providers +- External stores via `useSyncExternalStore` and selector subscriptions +- Re-render isolation: when context wins vs when a store wins - App Router file conventions (`layout`, `page`, `loading`, `error`) - Type-safe design tokens as a single origin for color, spacing, and type scale - Core Web Vitals: LCP, CLS, and INP tracked against a budget @@ -28,8 +32,8 @@ Front-end work has changed a lot since the SPA-everything era: rendering moved b - Scaffold: Next.js 15 App Router, React 19, strict TypeScript, Vitest + Testing Library, GitHub Actions CI, and a design-token stylesheet. The home route lists the planned concepts using an accessible `ConceptCard` component. - Design tokens: a typed token tree in `src/tokens` that generates the app's CSS custom properties. The root layout injects the generated `:root` rule at render time, so the token module is the runtime source and there is no second copy to drift. See the `/tokens` route. - Server Components + streaming: the `/streaming` route is a Server Component whose three cards are each their own async Server Component behind a Suspense boundary. The shell flushes first, then each card streams in as its own fetch settles, fastest first. Data comes from a small simulated layer built on a request-scoped memoizing loader (the `React.cache()` pattern), so components that read the same key during one render share a single fetch and a rejection is cached the same way. Tests cover the loader dedup, out-of-order settle order, and the async components rendered to static markup. See the `/streaming` route. - - Server actions & optimistic UI: the `/data-patterns` route mutates a note list through a Server Action, so the browser never runs a fetch handler or a client API layer. `useActionState` holds the confirmed list plus the last error, `useOptimistic` overlays the in-flight note on top of it, and `useFormStatus` drives the pending button. The overlay is only a prediction, so React discards it when the action settles and a rejected write reverts on its own with no manual rollback. The mutation core (`applyAdd`) takes an injected store, which keeps validation, the fail path, and the store-throws path testable without the Next runtime. Tests cover trimming and length validation, the optimistic reducer folding pending dispatches newest-first, and confirmed state staying put on every failure mode. See the `/data-patterns` route. +- Client state patterns: the `/state-management` route runs one pure cart reducer in two hosts. Context + `useReducer` (split state/dispatch) re-renders every state consumer on any field change. An external store on `useSyncExternalStore` lets each leaf select a slice and skip the render when that slice is unchanged. Typing the note field is the repro: context bumps badge and total counters; the store leaves them alone. ## Stack @@ -85,6 +89,14 @@ async function onSubmit(formData: FormData) { // fail or a thrown store -> confirmed list unchanged, error set ``` +The same cart reducer can sit in Context or in an external store. Selectors decide who re-renders: + +```tsx +const store = createReducerStore(cartReducer, EMPTY_CART) +const count = useStoreSelector(store, selectItemCount) +store.dispatch({ type: 'setNote', note: 'gift' }) // count subscriber stays quiet +``` + Styles reference tokens through a typed helper, so a bad name fails to compile: ```tsx @@ -99,5 +111,7 @@ import { token } from '@/tokens' ``` app/ App Router routes, layout, and global tokens src/components/ shared, tested UI primitives +src/state/ pure cart reducer, context provider, external store +src/tokens/ typed design tokens and CSS variable generation test/ Vitest specs and setup ``` diff --git a/app/page.tsx b/app/page.tsx index b7a9b15..bf517cc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,6 +20,12 @@ const CONCEPTS: readonly Concept[] = [ status: 'done', href: '/data-patterns' }, + { + title: 'Client state: context vs store', + description: 'Same pure reducer, two hosts: React Context re-renders every consumer; a store selects slices.', + status: 'done', + href: '/state-management' + }, { title: 'Design tokens', description: 'A typed token system that stays the single origin for color, spacing, and type scale.', diff --git a/app/state-management/demo.tsx b/app/state-management/demo.tsx new file mode 100644 index 0000000..3c7bea4 --- /dev/null +++ b/app/state-management/demo.tsx @@ -0,0 +1,155 @@ +'use client' +import { useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { + EMPTY_CART, + cartReducer, + selectItemCount, + selectNote, + selectTotal, + type CartAction, + type CartItem, + type CartState +} from '@/state/cart' +import { CartProvider, useCartDispatch, useCartState } from '@/state/cart-context' +import { createReducerStore, useStoreSelector, type ReducerStore } from '@/state/create-store' +import { token } from '@/tokens' +const CATALOG: readonly Omit[] = [ + { id: 'mug', name: 'Mug', price: 12 }, + { id: 'sticker', name: 'Sticker', price: 3 } +] +const card: CSSProperties = { + padding: token('space', '4'), + border: '1px solid var(--color-border)', + borderRadius: token('radius', 'md'), + background: 'var(--color-surface)', + display: 'grid', + gap: token('space', '3') +} +const field: CSSProperties = { + padding: token('space', '2'), + borderRadius: token('radius', 'md'), + border: '1px solid var(--color-border)', + background: 'var(--color-bg)', + color: 'var(--color-text)' +} +function Renders({ id }: { id: string }) { + const n = useRef(0) + n.current += 1 + return ( + + renders: {n.current} + + ) +} +function Panel({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} +function AddButtons({ onAdd }: { onAdd: (item: Omit) => void }) { + return ( +
+ {CATALOG.map((item) => ( + + ))} +
+ ) +} +function Line({ label, value, id, testId }: { label: string; value: string | number; id: string; testId: string }) { + return ( +

+ {label}: {value} +

+ ) +} +// Context leaves each call useCartState, so any field change re-renders all of them. +function CtxBadge() { + const { items } = useCartState() + return s + i.qty, 0)} id="ctx-badge" testId="ctx-count" /> +} +function CtxTotal() { + const { items } = useCartState() + return s + i.price * i.qty, 0)}`} id="ctx-total" testId="ctx-total" /> +} +function CtxNote() { + const { note } = useCartState() + const dispatch = useCartDispatch() + return ( + + ) +} +function ContextPanel() { + const dispatch = useCartDispatch() + return ( + + dispatch({ type: 'add', item })} /> + + + + + + ) +} +// Store leaves each select one slice, so setNote leaves badge and total alone. +type CartStore = ReducerStore +function StoreBadge({ store }: { store: CartStore }) { + const count = useStoreSelector(store, selectItemCount) + return +} +function StoreTotal({ store }: { store: CartStore }) { + const total = useStoreSelector(store, selectTotal) + return +} +function StoreNote({ store }: { store: CartStore }) { + const note = useStoreSelector(store, selectNote) + return ( + + ) +} +function StorePanel({ store }: { store: CartStore }) { + return ( + + store.dispatch({ type: 'add', item })} /> + + + + + + ) +} +export function StateDemo() { + const [resetKey, setResetKey] = useState(0) + const store = useMemo(() => createReducerStore(cartReducer, EMPTY_CART), [resetKey]) + return ( +
+
+ + + + +
+ +
+ ) +} diff --git a/app/state-management/page.tsx b/app/state-management/page.tsx new file mode 100644 index 0000000..406e1bc --- /dev/null +++ b/app/state-management/page.tsx @@ -0,0 +1,27 @@ +import { token } from '@/tokens' +import { StateDemo } from './demo' +export const metadata = { + title: 'Client state: reducer + context vs a store - Modern Frontend Lab' +} +export default function StateManagementPage() { + return ( +
+

Client state: reducer + context vs a store

+

+ Both panels share the same pure cart reducer. Context is on the left; an external store with + useSyncExternalStore selectors is on the right. Type in the note field and watch the render counters: + context re-renders every state consumer; the store only re-renders components whose selected value changed. +

+ +
+

When each wins

+

+ Context fits tree-scoped UI that updates infrequently or where most consumers need most of the state. Split + state and dispatch contexts so pure dispatchers stay quiet. An external store wins when many leaves each need a + thin slice, updates are high-frequency, or non-React code shares the state. Selectors must return stable values; + a fresh object every call throws away the re-render savings. +

+
+
+ ) +} diff --git a/src/state/cart-context.tsx b/src/state/cart-context.tsx new file mode 100644 index 0000000..be6b610 --- /dev/null +++ b/src/state/cart-context.tsx @@ -0,0 +1,31 @@ +'use client' +import { createContext, useContext, useReducer, type Dispatch, type ReactNode } from 'react' +import { cartReducer, EMPTY_CART, type CartAction, type CartState } from './cart' +// Split state and dispatch so components that only dispatch do not re-render +// when state changes. Dispatch identity is stable under useReducer. +const CartStateContext = createContext(null) +const CartDispatchContext = createContext | null>(null) +export function CartProvider({ + children, + initial = EMPTY_CART +}: { + children: ReactNode + initial?: CartState +}) { + const [state, dispatch] = useReducer(cartReducer, initial) + return ( + + {children} + + ) +} +export function useCartState(): CartState { + const state = useContext(CartStateContext) + if (state === null) throw new Error('useCartState must be used inside CartProvider') + return state +} +export function useCartDispatch(): Dispatch { + const dispatch = useContext(CartDispatchContext) + if (dispatch === null) throw new Error('useCartDispatch must be used inside CartProvider') + return dispatch +} diff --git a/src/state/cart.ts b/src/state/cart.ts new file mode 100644 index 0000000..5140af8 --- /dev/null +++ b/src/state/cart.ts @@ -0,0 +1,68 @@ +export interface CartItem { + id: string + name: string + price: number + qty: number +} +export interface CartState { + items: readonly CartItem[] + // Changes often and is unrelated to the badge/total; useful for showing re-render cost. + note: string +} +export type CartAction = + | { type: 'add'; item: Omit; qty?: number } + | { type: 'remove'; id: string } + | { type: 'setQty'; id: string; qty: number } + | { type: 'setNote'; note: string } + | { type: 'clear' } +export const EMPTY_CART: CartState = { items: [], note: '' } +export function cartReducer(state: CartState, action: CartAction): CartState { + switch (action.type) { + case 'add': { + const qty = action.qty ?? 1 + if (qty <= 0) return state + const existing = state.items.find((i) => i.id === action.item.id) + if (existing) { + return { + ...state, + items: state.items.map((i) => (i.id === action.item.id ? { ...i, qty: i.qty + qty } : i)) + } + } + return { ...state, items: [...state.items, { ...action.item, qty }] } + } + case 'remove': { + if (!state.items.some((i) => i.id === action.id)) return state + return { ...state, items: state.items.filter((i) => i.id !== action.id) } + } + case 'setQty': { + if (action.qty < 0) return state + const target = state.items.find((i) => i.id === action.id) + if (!target) return state + if (action.qty === 0) return { ...state, items: state.items.filter((i) => i.id !== action.id) } + if (target.qty === action.qty) return state + return { + ...state, + items: state.items.map((i) => (i.id === action.id ? { ...i, qty: action.qty } : i)) + } + } + case 'setNote': + if (state.note === action.note) return state + return { ...state, note: action.note } + case 'clear': + if (state.items.length === 0 && state.note === '') return state + return EMPTY_CART + default: { + const _exhaustive: never = action + return _exhaustive + } + } +} +export function selectItemCount(state: CartState): number { + return state.items.reduce((sum, i) => sum + i.qty, 0) +} +export function selectTotal(state: CartState): number { + return state.items.reduce((sum, i) => sum + i.price * i.qty, 0) +} +export function selectNote(state: CartState): string { + return state.note +} diff --git a/src/state/create-store.ts b/src/state/create-store.ts new file mode 100644 index 0000000..4ae8c22 --- /dev/null +++ b/src/state/create-store.ts @@ -0,0 +1,73 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react' +export type Listener = () => void +export type Unsubscribe = () => void +export interface Store { + getState(): T + subscribe(listener: Listener): Unsubscribe +} +export interface ReducerStore extends Store { + dispatch(action: A): void +} +export function createReducerStore( + reducer: (state: T, action: A) => T, + initial: T +): ReducerStore { + let state = initial + const listeners = new Set() + return { + getState: () => state, + dispatch(action) { + const next = reducer(state, action) + // Bail when the reducer returns the same reference (no-op actions). + if (Object.is(next, state)) return + state = next + for (const listener of listeners) listener() + }, + subscribe(listener) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + } +} +// Only re-render when the selected value fails isEqual. Context cannot do this. +export function useStoreSelector( + store: Store, + selector: (state: T) => S, + isEqual: (a: S, b: S) => boolean = Object.is +): S { + const selectorRef = useRef(selector) + selectorRef.current = selector + const isEqualRef = useRef(isEqual) + isEqualRef.current = isEqual + const cache = useRef<{ snapshot: T; selected: S } | null>(null) + const getSelection = useCallback((): S => { + const snapshot = store.getState() + const prev = cache.current + if (prev !== null && Object.is(prev.snapshot, snapshot)) return prev.selected + const selected = selectorRef.current(snapshot) + if (prev !== null && isEqualRef.current(prev.selected, selected)) { + cache.current = { snapshot, selected: prev.selected } + return prev.selected + } + cache.current = { snapshot, selected } + return selected + }, [store]) + const subscribe = useCallback( + (onStoreChange: Listener): Unsubscribe => + store.subscribe(() => { + const prev = cache.current + const snapshot = store.getState() + const selected = selectorRef.current(snapshot) + if (prev !== null && isEqualRef.current(prev.selected, selected)) { + cache.current = { snapshot, selected: prev.selected } + return + } + cache.current = { snapshot, selected } + onStoreChange() + }), + [store] + ) + return useSyncExternalStore(subscribe, getSelection, getSelection) +} diff --git a/test/state.test.tsx b/test/state.test.tsx new file mode 100644 index 0000000..2e06336 --- /dev/null +++ b/test/state.test.tsx @@ -0,0 +1,121 @@ +import { act, render, renderHook, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { EMPTY_CART, cartReducer, selectItemCount, selectNote, selectTotal, type CartState } from '@/state/cart' +import { CartProvider, useCartDispatch, useCartState } from '@/state/cart-context' +import { createReducerStore, useStoreSelector } from '@/state/create-store' +const mug = { id: 'mug', name: 'Mug', price: 12 } +const sticker = { id: 'sticker', name: 'Sticker', price: 3 } +describe('cartReducer', () => { + it('adds, merges qty, remove/setQty edges, note, clear, and selectors', () => { + const once = cartReducer(EMPTY_CART, { type: 'add', item: mug, qty: 2 }) + expect(once.items).toEqual([{ ...mug, qty: 2 }]) + expect(cartReducer(once, { type: 'add', item: mug, qty: 3 }).items[0]?.qty).toBe(5) + const state: CartState = { items: [{ ...mug, qty: 2 }, { ...sticker, qty: 1 }], note: '' } + expect(cartReducer(state, { type: 'add', item: mug, qty: 0 })).toBe(state) + expect(cartReducer(state, { type: 'remove', id: 'mug' }).items.map((i) => i.id)).toEqual(['sticker']) + expect(cartReducer(state, { type: 'remove', id: 'x' })).toBe(state) + expect(cartReducer(state, { type: 'setQty', id: 'mug', qty: 4 }).items[0]?.qty).toBe(4) + expect(cartReducer(state, { type: 'setQty', id: 'mug', qty: 0 }).items.map((i) => i.id)).toEqual(['sticker']) + expect(cartReducer(state, { type: 'setQty', id: 'mug', qty: -1 })).toBe(state) + expect(cartReducer(state, { type: 'setQty', id: 'mug', qty: 2 })).toBe(state) + const noted = cartReducer(EMPTY_CART, { type: 'setNote', note: 'gift' }) + expect(noted.note).toBe('gift') + expect(cartReducer(noted, { type: 'setNote', note: 'gift' })).toBe(noted) + expect(cartReducer({ items: [{ ...mug, qty: 1 }], note: 'x' }, { type: 'clear' })).toEqual(EMPTY_CART) + expect(cartReducer(EMPTY_CART, { type: 'clear' })).toBe(EMPTY_CART) + expect(selectItemCount(state)).toBe(3) + expect(selectTotal(state)).toBe(2 * 12 + 3) + expect(selectNote(noted)).toBe('gift') + expect(selectItemCount(EMPTY_CART)).toBe(0) + }) +}) +describe('createReducerStore + useStoreSelector', () => { + it('notifies, skips no-ops, isolates re-renders by slice, equality, and clear', () => { + const store = createReducerStore(cartReducer, EMPTY_CART) + const seen: number[] = [] + const unsub = store.subscribe(() => { + seen.push(selectItemCount(store.getState())) + }) + store.dispatch({ type: 'add', item: mug, qty: 2 }) + store.dispatch({ type: 'setNote', note: 'a' }) + store.dispatch({ type: 'setNote', note: 'a' }) + expect(seen).toEqual([2, 2]) + unsub() + store.dispatch({ type: 'add', item: mug }) + expect(seen).toEqual([2, 2]) + const store2 = createReducerStore(cartReducer, EMPTY_CART) + let countRenders = 0 + let noteRenders = 0 + const count = renderHook(() => { + countRenders += 1 + return useStoreSelector(store2, selectItemCount) + }) + const note = renderHook(() => { + noteRenders += 1 + return useStoreSelector(store2, selectNote) + }) + const countBefore = countRenders + const noteBefore = noteRenders + act(() => { + store2.dispatch({ type: 'setNote', note: 'wrap' }) + }) + expect(note.result.current).toBe('wrap') + expect(noteRenders).toBeGreaterThan(noteBefore) + expect(countRenders).toBe(countBefore) + act(() => { + store2.dispatch({ type: 'add', item: mug, qty: 2 }) + }) + expect(count.result.current).toBe(2) + const store3 = createReducerStore(cartReducer, EMPTY_CART) + store3.dispatch({ type: 'add', item: mug, qty: 1 }) + const { result } = renderHook(() => + useStoreSelector(store3, (s) => ({ count: selectItemCount(s) }), (a, b) => a.count === b.count) + ) + const first = result.current + act(() => { + store3.dispatch({ type: 'setNote', note: 'noop' }) + }) + expect(result.current).toBe(first) + act(() => { + store3.dispatch({ type: 'clear' }) + }) + expect(result.current.count).toBe(0) + }) +}) +describe('CartProvider', () => { + function Shell() { + const state = useCartState() + const dispatch = useCartDispatch() + return ( + <> + {selectItemCount(state)} + + + + ) + } + it('shares state, seeds initial, and throws outside the provider', async () => { + const user = userEvent.setup() + render( + + + + ) + expect(screen.getByTestId('count')).toHaveTextContent('2') + await user.click(screen.getByRole('button', { name: 'add' })) + expect(screen.getByTestId('count')).toHaveTextContent('3') + await user.click(screen.getByRole('button', { name: 'clear' })) + expect(screen.getByTestId('count')).toHaveTextContent('0') + function Outside() { + useCartState() + return null + } + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(() => render()).toThrow(/CartProvider/) + spy.mockRestore() + }) +})