renderpx

State Architecture in Practice

Progressive complexity, production patterns, and what senior engineers get wrong

Progressive Complexity

The same idea—put state as close as possible to where it's used—applies when a feature grows. Below we take one feature, a product filter, and build it five ways: from state that lives in a single component to state that crosses the whole app. Each step adds coordination needs and shows when to reach for the next pattern (lifted state, URL, server, global).

Example 1: Local State

Simple

100 products, single page

The simplest case: a filter that only affects one component.

Preview
6 results
  • Widget A
  • Widget B
  • Gadget X
  • Gadget Y
  • Tool Alpha
  • Tool Beta
tsx
Loading...

Why this works

This works because: • Filter state is only used here • No other component needs to know about it • Performance is fine with 100 items • Simple to understand and maintain

When this breaks

When you need the filter in multiple places (e.g., a separate FilterBar component, or showing filter count in header)

Production Patterns

Here's how I actually use these patterns in production applications.

Scenario A: E-commerce Checkout

The Constraints
  • PCI compliance requires no card data in client state
  • Cart must persist across sessions and devices
  • Checkout flow has 4 steps with form state
  • Need optimistic updates for UX
The Decision

Cart data: Server state with React Query

Form state: Local state per step

Current step: URL parameter

Payment token: Ephemeral, never stored

The Trade-offs

Accepted: Slightly more complex data flow. Gained: Clear separation of concerns, easier to audit for compliance, natural boundaries for testing.

Scenario B: Collaborative Dashboard

The Constraints
  • Multiple users editing same data in real-time
  • Heavy calculations for chart rendering
  • Need to support offline mode
  • Filter/sort state should be shareable
The Decision

Data: WebSocket + Zustand with optimistic updates

Filters: URL state (shareable links)

Chart config: Local state per widget

UI state: Local state (sidebar open, etc.)

What I'd Do Differently

Initially used Context for all data. Performance tanked. Moved to Zustand with selectors. Should have started there.

Decision Signals

Each transition in the examples above is triggered by a specific kind of friction. Here are the concrete signals that tell you it's time to move to the next level — not earlier.

LocalLifted
Move up when you see
  • Two sibling components need to read the same value
  • A parent needs to react to something that happens in a child
  • You find yourself duplicating useState in two places and keeping them in sync
Don't move yet if: One level of prop passing is fine. Lifting is cheap — don't skip it for global state.
LiftedURL
Move up when you see
  • The user would be frustrated if a browser refresh cleared the state
  • You want to share a specific view with another person via a link
  • Analytics should capture the actual filter/search combination users are using
  • The back button should restore the previous filter, not go to the previous page
Don't move yet if: State that's purely transient (a dropdown open/closed) doesn't belong in the URL.
URLServer
Move up when you see
  • The dataset is too large to filter/sort client-side (>1k items, or growing)
  • Multiple URL params combine in ways that need the server to compute the result
  • The same data is fetched in multiple places and should be cached
Don't move yet if: URL state with client-side filtering is faster to implement and perfectly adequate for small datasets.
ServerGlobal
Move up when you see
  • A user action in one feature needs to immediately update what another unrelated feature displays
  • State is genuinely cross-cutting: the same value affects 5+ components across different subtrees
  • You need to write to state from outside the React tree (WebSocket handler, service worker)
Don't move yet if: Server state (React Query) and URL state handle most cross-component coordination without global client state.