State management at scale
In this series (12 parts)
- What frontend system design covers
- Rendering strategies: CSR, SSR, SSG, ISR
- Performance fundamentals: Core Web Vitals
- Loading performance and resource optimization
- State management at scale
- Component architecture and design systems
- Client-side caching and offline support
- Real-time on the frontend
- Frontend security
- Scalability for frontend systems
- Accessibility as a system design concern
- Monitoring and observability for frontends
State is any data that changes over time and affects what the user sees. A counter value is state. The list of items in a shopping cart is state. Whether a modal is open is state. The user’s profile fetched from an API is state. Each of these has different characteristics, different lifetimes, and needs different management strategies. Treating them all the same is how frontend applications become unmaintainable. If your loading performance is solid but users report stale data and flickering UIs, state management is likely the problem.
Categories of state
Not all state is the same. The first step toward sane state management is recognizing four distinct categories:
Local state lives inside a single component. A toggle’s open/closed status, an input’s current value, a dropdown’s selected option. This state has no business being global.
Shared state needs to be accessed by multiple components that are not in a parent-child relationship. The current user, a shopping cart, theme preferences. This state must be lifted out of individual components.
Server state is data that lives on the server and is cached on the client. Product lists, user profiles, search results. This state has its own lifecycle: it can be stale, it can be revalidated, it can be invalidated by mutations.
URL state is stored in the URL through path parameters, query strings, and hash fragments. Search filters, pagination, and selected tabs. This state must survive page refreshes and be shareable via links.
graph TD A[Frontend State] --> B[Local State] A --> C[Shared State] A --> D[Server State] A --> E[URL State] B --> B1["useState, useReducer"] B --> B2["Component-scoped, short-lived"] C --> C1["Context, Redux, Zustand"] C --> C2["Cross-component, session-lived"] D --> D1["React Query, SWR, RTK Query"] D --> D2["Cached, revalidated, has loading/error states"] E --> E1["URL params, search params"] E --> E2["Shareable, survives refresh"]
Four categories of frontend state. Each has different tools, lifetimes, and management patterns.
The flux architecture
Before modern state libraries existed, frontend applications managed state through direct DOM manipulation and event listeners. This worked for small applications but became chaotic at scale. Facebook experienced this firsthand and introduced the Flux architecture in 2014.
Flux enforces a unidirectional data flow:
graph LR A[Action] --> B[Dispatcher] B --> C[Store] C --> D[View] D -->|User interaction| A style A fill:#cce5ff style B fill:#d4edda style C fill:#fff3cd style D fill:#f8d7da
The Flux data flow. Actions describe what happened, the dispatcher routes them to stores, stores update state, views re-render.
The key insight: data flows in one direction. Views never modify stores directly. They dispatch actions that describe what happened, and stores decide how to update in response. This makes state changes predictable and traceable.
Redux: the trade-offs
Redux is the most influential implementation of the Flux pattern. It introduces three principles:
- Single source of truth. All application state lives in one store.
- State is read-only. The only way to change state is by dispatching an action.
- Changes via pure functions. Reducers are pure functions that take the current state and an action, then return the new state.
// Action
const addItem = (item) => ({ type: 'cart/addItem', payload: item });
// Reducer
function cartReducer(state = { items: [] }, action) {
switch (action.type) {
case 'cart/addItem':
return { ...state, items: [...state.items, action.payload] };
case 'cart/removeItem':
return { ...state, items: state.items.filter(i => i.id !== action.payload) };
default:
return state;
}
}
When Redux works well
Redux excels when you have complex state transitions that involve multiple parts of the state tree. An e-commerce checkout that must coordinate cart items, shipping options, payment methods, and promotional codes benefits from having all state in one place with clearly defined transitions.
Redux DevTools are exceptional. Time-travel debugging, action replay, and state diffing make it possible to reproduce and diagnose bugs that would be nearly impossible to track in ad-hoc state management.
When Redux is overkill
Redux adds significant boilerplate: actions, action creators, reducers, selectors, middleware. For applications where most state is either local or server-sourced, this overhead is not justified.
Redux Toolkit (RTK) reduces the boilerplate substantially with createSlice and createAsyncThunk, but the conceptual overhead remains. Every developer on the team must understand actions, reducers, middleware, selectors, and normalization.
Server state with React Query and SWR
The biggest shift in frontend state management in recent years is the recognition that server state is fundamentally different from client state. Server state is:
- Asynchronous. It requires network requests to fetch and mutate.
- Shared ownership. The server is the source of truth, not the client.
- Potentially stale. Another user or process may have changed it since you last fetched.
Libraries like React Query (TanStack Query) and SWR treat server state as a cache that needs management, not as application state that needs reducers.
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
staleTime: 5 * 60 * 1000,
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <ProductGrid products={data} />;
}
React Query handles caching, deduplication, background revalidation, retry logic, pagination, and garbage collection out of the box. This eliminates the need to store API responses in Redux and manually manage loading and error states.
Stale-while-revalidate
The stale-while-revalidate pattern serves cached data immediately (fast) while fetching fresh data in the background. When the fresh data arrives, the UI updates seamlessly.
This pattern works because most server data does not change between consecutive requests. The user gets an instant response 99% of the time, and the occasional stale-to-fresh transition is barely noticeable.
Optimistic updates
An optimistic update assumes a mutation will succeed and updates the UI immediately, before the server confirms. If the mutation fails, the update is rolled back.
const mutation = useMutation({
mutationFn: (newTodo) => fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
return { previous };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Optimistic updates make the application feel instant. The user adds a todo and sees it appear immediately. The network request happens in the background. If it fails, the todo disappears and an error message appears.
Use optimistic updates for actions with high success rates and low conflict potential: adding items, toggling favorites, updating settings. Avoid them for financial transactions or operations where failure has serious consequences.
State normalization
When your store contains nested, duplicated data, updates become error-prone. If a user’s name appears in 15 different places in your state tree, updating it requires finding and changing all 15 copies.
Normalization stores each entity type in a flat lookup table keyed by ID:
// Denormalized (problematic)
{
posts: [
{ id: 1, title: 'Hello', author: { id: 1, name: 'Alice' }, comments: [
{ id: 1, text: 'Great', author: { id: 2, name: 'Bob' } }
]}
]
}
// Normalized (better)
{
posts: { 1: { id: 1, title: 'Hello', authorId: 1, commentIds: [1] } },
users: { 1: { id: 1, name: 'Alice' }, 2: { id: 2, name: 'Bob' } },
comments: { 1: { id: 1, text: 'Great', authorId: 2 } }
}
When Alice changes her name, you update one entry in users. Every component that references user 1 automatically renders the new name through selectors.
Libraries like normalizr and RTK’s createEntityAdapter automate normalization. React Query sidesteps the problem entirely by caching responses at the query key level and using invalidation to keep data fresh.
Choosing the right combination
Most applications need a combination of state management approaches:
graph TD
A[State Decision] --> B{Does it affect only one component?}
B -->|Yes| C[useState / useReducer]
B -->|No| D{Does it come from the server?}
D -->|Yes| E[React Query / SWR]
D -->|No| F{Is it in the URL?}
F -->|Yes| G[URL search params]
F -->|No| H{Is the app state complex?}
H -->|Yes| I[Redux / Zustand]
H -->|No| J[React Context]
Decision tree for choosing a state management approach. Start with the simplest option and escalate only when complexity demands it.
The principle is: use the simplest tool that solves the problem. Start with useState. If you need to share state between a few components, try lifting state up or using Context. If you have complex server state, add React Query. If you still have complex client-side state transitions, consider Redux or Zustand.
What you should avoid is reaching for Redux on day one because it is the “industry standard.” The industry has evolved. Most state in modern applications is server state, and specialized libraries handle it far better than general-purpose stores.
What comes next
State flows through components, but how those components are structured determines whether the state management stays clean or devolves into prop-drilling chaos. Component architecture and design systems covers the patterns for building component hierarchies that scale with your team and your product.