State Management at Scale
The State Taxonomy Nobody Teaches
Most frontend codebases shove everything into a single global store. User profile, API responses, form inputs, UI toggles, URL parameters — all living in one Redux store with 200 reducers. This is the architectural equivalent of putting every file on your desktop.
State has categories. Each category has fundamentally different characteristics — staleness, ownership, scope, and persistence. Treating them the same is why your state management "doesn't scale."
Think of state like mail in a building. Server state is mail from outside — it arrives, it might be stale, and someone else controls the source of truth. Client state is notes you write to yourself — you own it entirely. URL state is the address on the building — it's public, shareable, and bookmarkable. Form state is a draft letter — temporary, local, and discarded when you're done. You wouldn't store all of these in the same mailbox. Each has a different lifecycle, different ownership, and different storage requirements.
The Four Categories of State
1. Server State
Data that originates from a remote source. The server is the source of truth, and your client copy is a cache that can become stale at any moment.
// This is server state — it came from an API
const user = { id: '123', name: 'Alice', plan: 'pro' };
const products = [{ id: 'p1', name: 'Widget', price: 29.99 }];
Server state has unique concerns that client state doesn't:
- Caching: How long is the data fresh?
- Deduplication: Two components requesting the same data shouldn't trigger two fetches
- Background refetching: Data should refresh when the user returns to the tab
- Optimistic updates: Show the expected result before the server confirms
- Retry and error recovery: Network failures need automatic retry with backoff
Putting server state in Redux means you must solve all of these yourself. Libraries like TanStack Query and SWR solve them out of the box.
// TanStack Query handles caching, deduplication, refetching, retries
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // fresh for 5 minutes
});
2. Client State
State that exists only in the browser. The server doesn't know about it and doesn't care. This is the only category where traditional state management (Context, Zustand, Jotai) is the right tool.
// True client state — no server equivalent
const [isSidebarOpen, setSidebarOpen] = useState(false);
const [selectedTheme, setSelectedTheme] = useState('dark');
const [activeTab, setActiveTab] = useState('overview');
Client state is typically small. If your global store has 50 slices and 45 of them are API responses, you don't have a client state problem — you have a server state problem being 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 shareable, bookmarkable, and survives page refreshes.
// /products?category=electronics&sort=price&page=3
// This IS the state. Don't duplicate it in a store.
const searchParams = useSearchParams();
const category = searchParams.get('category');
const sort = searchParams.get('sort');
const page = Number(searchParams.get('page')) ?? 1;
A common anti-pattern: reading URL params into a Redux store on mount, then using the store everywhere. Now you have two sources of truth — the URL and the store. They will drift. The URL IS the state. Read from it directly. When you need to change it, update the URL and let components react to that change.
4. Form State
Ephemeral state tied to a user's in-progress input. It has a clear lifecycle: initialize → edit → submit → discard. Form state rarely needs to be global.
// React 19 form state with useActionState
const [state, formAction, isPending] = useActionState(submitForm, initialState);
// Or react-hook-form for complex forms
const { register, handleSubmit, formState } = useForm<CheckoutForm>();
Why Global State Is Overused
Here's the thing: Redux became the default because it appeared before better alternatives existed. In 2016, there was no TanStack Query for server state, no useSearchParams for URL state, no useActionState for form state. Redux was the only game in town, so everything went there.
The cost of over-centralization:
- Boilerplate explosion — Actions, reducers, selectors, thunks for every API endpoint
- Unnecessary re-renders — One slice update triggers selector recalculations across unrelated components
- Stale data bugs — No automatic cache invalidation means you build it yourself (and get it wrong)
- Testing complexity — Every test needs a store setup with mock state for unrelated slices
Client State: Choosing the Right Tool
Once you've moved server state out of your store, you'll find the remaining client state is surprisingly small. Pick your tool based on update frequency:
| Tool | Best For | Re-render Behavior |
|---|---|---|
| React Context | Low-frequency state (theme, auth, locale) | All consumers re-render on any change |
| Zustand | Medium-to-high frequency, multiple slices | Selector-based — only subscribing components re-render |
| Jotai | Fine-grained atomic state | Atom-level subscriptions — minimal re-renders |
| URL (searchParams) | Shareable, bookmarkable state | Components re-render on navigation |
// Zustand — minimal API, selector subscriptions
import { create } from 'zustand';
interface UIStore {
sidebarOpen: boolean;
toggleSidebar: () => void;
activeModal: string | null;
openModal: (id: string) => void;
closeModal: () => void;
}
const useUIStore = create<UIStore>((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
activeModal: null,
openModal: (id) => set({ activeModal: id }),
closeModal: () => set({ activeModal: null }),
}));
// Component subscribes to ONLY sidebarOpen — doesn't re-render when activeModal changes
const isSidebarOpen = useUIStore((s) => s.sidebarOpen);
State Machines with XState
Ever had a bug where your UI shows a loading spinner and an error message at the same time? State machines eliminate that entire category of bug. For complex state with well-defined transitions — multi-step wizards, authentication flows, payment processing — they make impossible states truly impossible.
import { createMachine, assign } from 'xstate';
const checkoutMachine = createMachine({
id: 'checkout',
initial: 'cart',
context: { items: [], error: null },
states: {
cart: {
on: { PROCEED: 'shipping' }
},
shipping: {
on: {
BACK: 'cart',
SUBMIT_ADDRESS: 'payment'
}
},
payment: {
on: {
BACK: 'shipping',
SUBMIT_PAYMENT: 'processing'
}
},
processing: {
invoke: {
src: 'processPayment',
onDone: 'confirmation',
onError: {
target: 'payment',
actions: assign({ error: (_, event) => event.data.message })
}
}
},
confirmation: { type: 'final' }
}
});
The machine makes illegal transitions impossible at the type level. You cannot go from cart directly to confirmation. You cannot submit payment from the shipping state. Every valid path is explicit.
When state machines are overkill
State machines add upfront design cost. They pay off when your state has 4+ states with complex transitions, when bugs come from impossible state combinations (loading AND error simultaneously), or when the flow has strict ordering requirements. For a simple boolean toggle or a list filter, useState or Zustand is simpler and perfectly adequate. Don't reach for XState for an isOpen flag.
Normalized State Shape
When client state includes relational data (lists of items that reference each other), normalize it like a database:
// Denormalized — duplicated data, update anomalies
const state = {
posts: [
{ id: '1', title: 'Hello', author: { id: 'u1', name: 'Alice' } },
{ id: '2', title: 'World', author: { id: 'u1', name: 'Alice' } },
// If Alice changes her name, you must update every post
]
};
// Normalized — single source of truth per entity
const state = {
posts: {
byId: { '1': { id: '1', title: 'Hello', authorId: 'u1' },
'2': { id: '2', title: 'World', authorId: 'u1' } },
allIds: ['1', '2']
},
users: {
byId: { 'u1': { id: 'u1', name: 'Alice' } },
allIds: ['u1']
}
};
Normalization eliminates data duplication, makes updates O(1) by ID, and prevents the classic bug where the same entity has different values in different parts of the state tree.
Selector Patterns for Derived State
This is a rule worth tattooing on your forearm: never store derived state. Compute it from base state using selectors:
// Bad — storing derived state
const state = {
items: [...],
totalPrice: 142.50, // derived from items
itemCount: 5, // derived from items
hasExpensiveItems: true, // derived from items
};
// Good — compute with selectors
const selectItems = (state) => state.items;
const selectTotalPrice = (state) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const selectItemCount = (state) =>
state.items.reduce((sum, item) => sum + item.quantity, 0);
const selectHasExpensiveItems = (state) =>
state.items.some(item => item.price > 100);
For expensive derivations, memoize the selector so it only recalculates when inputs change:
import { createSelector } from 'reselect';
const selectExpensiveReport = createSelector(
[selectItems, selectFilters],
(items, filters) => {
// Only runs when items or filters change
return items
.filter(matchesFilters(filters))
.sort(byCriteria(filters.sortBy))
.map(enrichWithMetrics);
}
);
The Colocation Principle
State should live as close to where it's used as possible. Start local, lift only when needed:
- Component state (
useState) — default. Use unless you have a reason not to - Shared between siblings — lift to parent
- Shared across distant components — Context (low frequency) or Zustand/Jotai (high frequency)
- Shared across routes — URL state or global store
- Persisted across sessions — localStorage + hydration or server
- 1Classify state into four categories: server state (TanStack Query/SWR), client state (useState/Zustand/Jotai), URL state (searchParams), and form state (useActionState/react-hook-form).
- 2Server state is a cache of remote data. Use TanStack Query or SWR — never Redux — for caching, deduplication, background refetching, and optimistic updates.
- 3Global client state should be small. If your store has 50 slices and 45 are API data, you're using the wrong tool for server state.
- 4React Context re-renders all consumers on any change. Use it for low-frequency state (theme, auth). Use Zustand or Jotai with selectors for high-frequency updates.
- 5Normalize relational state like a database — byId maps eliminate duplication and make updates O(1).
- 6Never store derived state. Compute it with selectors, memoize expensive derivations with createSelector.
- 7Colocate state: start with useState, lift only when sharing is needed. Most state is more local than you think.
Q: You're joining a team whose Redux store has 80 slices — 60 of which are API response caches, 10 are form states, and 10 are true UI state. How would you refactor this?
A strong answer: Migrate the 60 API slices to TanStack Query (get caching, deduplication, background refetch, and optimistic updates for free). Replace the 10 form slices with react-hook-form or useActionState (form state is ephemeral, doesn't belong in a global store). Keep only the 10 true UI slices, and evaluate whether they can be simplified with Zustand or even colocated as local state. The Redux store shrinks from 80 slices to 0-10, with better behavior in every category.