Context + useReducer State Management
App state with a reducer, typed actions, and context without extra re-renders.
How it works
Shared app state without a state library: one reducer with typed actions owns the cart, React context makes it available everywhere, and components re-render only when what they show changes. Switch how the state is shared and watch the render counters, then try undo, time travel, a reload, a second tab and a failing checkout.
- One reducer, typed actions: every change is an action object; an unhandled action type is a compile error.
- A context value is all or nothing: every consumer re-renders when it changes, so { state, dispatch } in one context re-renders everyone.
- Subscribe to slices: put a store in context and let each component select only what it shows.
- Immutable states are snapshots: undo, redo and time travel are just keeping old states around.
Read moreShow less
Where you'd use it
- Carts, editors, wizards and dashboards: state many components read and change.
- Anywhere you need undo, an audit trail, or to replay what happened.
- Apps that should survive a reload or stay in sync across tabs.
- Before reaching for a state library: this is what those libraries do inside.
How we built it
shopReducer handles a discriminated union of actions (cart/add, history/undo, checkout/failed…) with a never check in its default branch. It never mutates: cart operations return the same object when nothing changes and a new one otherwise. Cart changes push a new state onto a history (50 deep); undo, redo and jumping from the log move one pointer, and a new change after undoing drops the future. Ids and timestamps are stamped by action creators, so the reducer stays pure and the log is part of the state.
The same reducer is shared three ways. Single context passes { state, dispatch }: a new object every render, so all ~35 consumers re-render on every action, even Add buttons that never read state. Split puts the stable dispatch in its own context, so dispatch-only components (memoized Add buttons, Clear cart) stop re-rendering; readers still all re-render. Selectors put a tiny store in context (its identity never changes) and each component calls useShopSelector, built on useSyncExternalStore with a memoized, equality-checked selected value, so only components whose slice changed re-render. Switching keeps everything because a session object hands the latest state to the next provider.
The catalog comes from the shared useQuery cache (server data stays out of the reducer). The cart is saved to localStorage as { version: 2, lines, discount }; on load it is parsed with Zod, migrated from v1 if needed, then checked against the catalog (unknown products dropped, quantities clamped). A storage event from another tab applies the same check and resets undo history. Checkout dispatches checkout/started (the cart looks empty right away), then succeeded or failed; because the real cart never left the history, failing is the rollback.
Best practices
Do: Keep reducers pure and state immutable; put ids, time and I/O in action creators and effects.
Avoid: Calling Date.now(), fetch or localStorage inside the reducer, or mutating the previous state.
Do: Split state and dispatch, or subscribe to slices with selectors, once many components share state.
Avoid: One context with a fresh { state, dispatch } object: every consumer re-renders on every change.
Do: Select primitives or memoize selected objects with an equality check.
Avoid: Selectors that build a new array or object every call: they re-render (or loop) every time.
Do: Read a value in an event handler when you only need it at click time.
Avoid: Subscribing a component to state it only needs when a button is pressed.
Do: Treat saved and synced state as untrusted: validate, migrate, reconcile.
Avoid: JSON.parse straight into the reducer: one old or edited value crashes the app.
Try it with the demo controls
- Sharing strategy
- Add a product in each mode: the scoreboard at the top keeps the latest count per strategy (Single ≈ 35 of 35, Split ≈ 22, Selectors 5–6), and the parts that re-rendered flash briefly.
- Undo / Redo
- Ctrl/⌘+Z and Shift+Ctrl/⌘+Z (or Ctrl+Y) work anywhere except inside text fields, which keep their own undo.
- Action log
- Select an older cart change: the cart and totals jump to that moment; later entries are marked “undone” and get discarded by the next change.
- Reload the page
- The cart comes back exactly; history and log start fresh.
- Open a second tab
- Change the cart in one tab: the other follows within a moment and logs “cart/synced”.
- Load a v1 save
- An old-format cart is migrated: the coupon becomes SAVE10, an unknown product is dropped and an over-stock quantity is reduced, with a note.
- Load a corrupted save
- The cart resets with a note instead of crashing.
- Checkout failure
- Server error: the cart comes back exactly, with Retry. Stock conflict: one line is reduced to what's available and listed.
- Checkout latency
- While “Placing order…” runs the cart is empty and locked; switch strategy meanwhile and it still settles.
Trade-offs and limits
- The selector hook re-implements what
use-sync-external-store/with-selectoror a state library gives you; it is short, but each selector must be stable or cheap. - Undo history and the log live in memory only; a reload keeps the cart, not the past.
- Cross-tab sync is last-write-wins on the whole cart; merging concurrent edits would need per-line versions.
- Optimistic checkout hides the cart while waiting; if the tab closes mid-order, the saved cart still holds the items and the order may or may not exist.
- Render counts measure committed renders in this build; React may render more in development (Strict Mode double renders).
Demo controls
Flashing parts = re-rendered by the action
Add a product to see how many components re-render.
- SingleSingle context
- not yet
- SplitSplit state/dispatch
- not yet
- SelectorsSelectors
- not yet
Cart
- Items
- 0
- Subtotal
- $0.00
- Discount
- $0.00
- Shipping
- —
- Total
- $0.00
Action log
Every dispatched action appears here. Select a cart change to jump to the state right after it.
Re-render history
Add a product: this shows how many components re-rendered for it under the current strategy.