Skip to content

State Categories and Decision Framework

advanced18 min read

The Dirty Secret of Frontend State

Here's something that took the React ecosystem about 8 years to figure out: not all state is the same. For years, we shoved everything into Redux — API responses, form inputs, sidebar toggles, URL parameters, WebSocket messages — and then wondered why our apps were slow, buggy, and painful to maintain.

The problem was never Redux itself. The problem was treating fundamentally different kinds of data as if they had the same requirements. That's like storing milk, documents, and fireworks in the same cabinet. They need different containers because they have different properties.

Mental Model

State is not a monolith — it's a spectrum with five distinct categories, each with different ownership, lifecycle, staleness behavior, and persistence needs. The right architecture assigns each category to the tool purpose-built for it. Mismatched tools cause 90% of state management pain: stale data bugs, unnecessary re-renders, boilerplate explosions, and impossible-to-test code.

The Five Categories of Frontend State

1. Server State

Data that originates from a remote source. The server is the source of truth, and your client copy is a stale cache that can become outdated at any moment. This is the category most teams get wrong.

const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 5 * 60_000,
});

Server state has concerns that no other category shares:

  • Caching — how long is this data fresh?
  • Deduplication — two components requesting the same data should not trigger two fetches
  • Background refetching — data should refresh when the user refocuses the tab
  • Optimistic updates — show expected results before the server confirms
  • Retry with backoff — network failures need automatic recovery

Putting this in Redux means you build all of that yourself. TanStack Query gives it to you out of the box.

2. Client UI State

State that exists only in the browser. The server doesn't know about it and doesn't care.

const [isSidebarOpen, setSidebarOpen] = useState(false);
const [selectedTheme, setSelectedTheme] = useState('dark');
const [activeTab, setActiveTab] = useState('overview');

This is typically small. If your "client state" store has 40 slices and 35 of them are API responses, you don't have a client state problem — you have a server state problem solved with the wrong tool.

3. URL State

State encoded in the URL — query parameters, path segments, hash fragments. URL state is special because it's the only state that's shareable and survives page refreshes without extra work.

// /products?category=electronics&sort=price&page=3
const searchParams = useSearchParams();
const category = searchParams.get('category');

If a user can't send their current view to a colleague by copying the URL, you've lost one of the web's superpowers.

4. Form State

Ephemeral state tied to a user's in-progress input. It has a clear lifecycle: initialize, edit, validate, submit, discard. Form state rarely needs to be global.

const { register, handleSubmit, formState } = useForm<CheckoutForm>();

5. Real-Time State

State pushed from external sources — WebSocket messages, Server-Sent Events, collaborative editing data. Real-time state has unique challenges around conflict resolution, ordering guarantees, and reconnection.

const [cursors, setCursors] = useState<Map<string, Cursor>>(new Map());
socket.on('cursor-move', ({ userId, position }) => {
  setCursors(prev => new Map(prev).set(userId, position));
});
Quiz
Your app has a product listing from an API, a search filter in the URL, a sidebar toggle, and a WebSocket-powered notification badge. How many state categories are involved?

The Colocation Principle

This is the single most important rule in state management, and it's deceptively simple:

State should live as close as possible to where it's used.

Global state is the last resort, not the default. Before reaching for Zustand or Redux, ask: can this be useState in the component that needs it? Can it be useSearchParams? Can it be a TanStack Query cache entry?

Quiz
A theme preference (light/dark) is used by 100+ components across the app. It changes at most once per session. Which tool fits best?

Why "Just Use Redux" Is Outdated

Let's be clear: Redux is not bad. Redux Toolkit is genuinely well-designed. But the ecosystem that made Redux the default answer for everything has changed dramatically.

In 2016, Redux was the only tool. There was no TanStack Query for server state, no useSearchParams for URL state, no useActionState for form state, no Zustand for lightweight client state. Redux was the Swiss Army knife because it was the only knife.

Today, specialized tools exist for every category. And specialized tools beat general-purpose ones every time:

CategoryRedux ApproachModern ApproachLines of Code
Server stateActions + thunks + reducers + selectors + manual cache invalidationuseQuery() with automatic caching, dedup, refetching~80 vs ~5
URL stateSync URL to store on mount, sync store to URL on change (two sources of truth)useSearchParams() or nuqs (URL IS the state)~40 vs ~3
Form stateControlled inputs through Redux dispatch (every keystroke dispatches)React Hook Form with uncontrolled inputs~60 vs ~10
Client UI stateRedux slice for sidebar toggle, modal state, active tabZustand store or local useState~30 vs ~5

The remaining use case for Redux is genuinely complex client state with strict requirements: time-travel debugging, action replay for bug reports, middleware pipelines for audit trails, or state machines too complex for simpler tools. That's maybe 5-10% of apps.

Quiz
Your team uses a single Redux store for everything: API data, form inputs, URL filters, and UI toggles. They report stale data bugs, unnecessary re-renders, and slow test suites. What's the root cause?

The Decision Framework

Here's the framework your team can use for any new piece of state. Ask these questions in order:

Key Rules
  1. 1Does it come from a server? Use TanStack Query (or SWR, RTK Query). Never put API responses in client state stores.
  2. 2Should it be in the URL? If the user should be able to share it, bookmark it, or use back/forward to navigate it — it's URL state. Use nuqs or useSearchParams.
  3. 3Is it form input? Use React Hook Form for complex forms, useActionState for simple server-action forms. Form state is ephemeral.
  4. 4Is it used by only one component (or a parent and its children)? Keep it local with useState or useReducer. Don't promote to global prematurely.
  5. 5Is it shared across unrelated components and updates frequently? Now you need a client state library. Zustand for top-down slices, Jotai for bottom-up atoms.
  6. 6Does it change rarely (theme, locale, auth)? React Context is fine. The re-render cost is negligible for infrequent updates.
  7. 7Is it pushed from an external source in real-time? You need WebSocket/SSE integration with conflict resolution. Consider CRDTs for collaborative state.

Common Anti-Patterns

What developers doWhat they should do
Putting API responses in Redux/Zustand and manually managing loading, error, caching, and refetching states
Server state has fundamentally different concerns than client state. Specialized tools solve problems that general stores force you to reinvent (badly).
Use TanStack Query which handles caching, deduplication, background refetching, retry, and garbage collection automatically
Reading URL params into a store on mount, then reading from the store everywhere
Two sources of truth always drift. The URL and the store will desync, causing bugs where the UI shows one filter but the URL says another.
Read directly from the URL with useSearchParams or nuqs. The URL IS the source of truth.
Making every piece of state global 'just in case' another component needs it later
Global state couples unrelated components. A change in one component's state can re-render components across the app. Colocation keeps blast radius small.
Start local (useState). Lift only when a second consumer actually appears. Premature globalization causes unnecessary coupling.
Using React Context for high-frequency state like cursor positions, animation values, or live search input
Context re-renders ALL consumers on any change. At 60fps, that means 60 full re-renders per second for every consuming component, even if they only care about one property.
Use Zustand or Jotai which support selector-based subscriptions for fine-grained re-renders
Common Trap

Beware the "abstraction before need" trap. Don't create a state management architecture on day one. Start with useState and TanStack Query. Only add Zustand or Jotai when you have a proven need for shared client state. Most apps need far less global state than developers expect — especially once server state is properly handled.

Putting It All Together

Here's what a well-architected state setup looks like for a typical production app:

// Server state — TanStack Query
const { data: products } = useQuery({
  queryKey: ['products', { category, sort, page }],
  queryFn: () => fetchProducts({ category, sort, page }),
});

// URL state — nuqs
const [category, setCategory] = useQueryState('category');
const [sort, setSort] = useQueryState('sort', parseAsStringEnum(['price', 'name', 'date']));
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));

// Client UI state — Zustand (only for truly shared state)
const isSidebarOpen = useUIStore((s) => s.sidebarOpen);

// Form state — React Hook Form
const { register, handleSubmit } = useForm<ReviewForm>();

// Local state — useState (default choice)
const [isDropdownOpen, setDropdownOpen] = useState(false);

Five categories, five tools, zero overlap. Each tool does what it's best at. That's the architecture.

Quiz
You're building a dashboard with: user profile from API, date range filter that should be shareable via URL, a collapsible panel, and a multi-step form wizard. Which setup is correct?
1/11