Front End Engineering
State Management Fundamentals
Understand what application state actually is, how it flows through a UI, what happens when it's managed poorly, and how to reason about the correct home for every piece of data in a production application.
The Bug That Wasn't a Bug
A senior engineer at Notion spent three days tracking down a bug where the sidebar's unread notification count showed 4 but the notification panel showed 7. Both were correct — they were reading from different sources of truth. The sidebar queried the local Redux store, which was updated optimistically on dismiss. The notification panel fetched fresh from the API. When a network error silently failed the dismiss, the store updated but the server didn't. Two numbers, two sources, one very confused user.
The bug wasn't in either component's logic. It was in the state architecture — specifically, in the decision to have two sources of truth for the same data. That decision was made incrementally, by different engineers, neither of whom saw the full picture. This is how most state management problems develop: not as a single bad decision, but as a sequence of locally reasonable choices that accumulate into a globally incoherent system.
State management is the discipline of deciding what your application remembers, where it remembers it, and how different parts of your UI stay in agreement about what the truth is. Get it right and your application feels fast, consistent, and predictable. Get it wrong and you spend your Fridays chasing bugs that only reproduce in specific combinations of user actions no test ever anticipated.
What State Actually Is
State is any data that, when it changes, should cause part of the UI to update. That's the complete definition. It's intentionally narrow — because a lot of what developers reach for a state store to manage is not actually state. It's derived data, or it's a prop, or it's a constant, or it's a ref. Misidentifying what needs to be state is the first error in most overengineered state systems.
Concept Anatomy — Application State
Concept
Application State
Type
Client-side data model
Used For
Driving UI re-renders in response to data changes
Managed By
useState, useReducer, Context, Zustand, Redux, React Query
Failure Mode
Multiple sources of truth → stale UI, inconsistent data, hard-to-trace bugs
State has four fundamental categories in a modern front end application, and confusing them is the root cause of most state management complexity. Each category has different update patterns, different lifetime characteristics, and different optimal storage locations. Treating all four identically — by shoving everything into a single Redux store, for example — is a common mistake that produces stores bloated with data that doesn't belong there and components that re-render in response to changes they don't care about.
Local UI State
Ephemeral state owned by a single component — open/closed, hover, focus, input value before submit, animation phase. Has no meaning outside the component. Disappears when the component unmounts.
Storage: useState or useReducer inside the component.
Server (Remote) State
Data that lives on the server and is fetched, cached, and synchronised. The source of truth is the backend. The UI is a snapshot. Stale data, loading states, background refetching, and cache invalidation are the defining challenges here.
Storage: React Query, SWR, Apollo Client — purpose-built for server state.
Global Client State
Application-wide client data that isn't from the server — authenticated user identity, active theme, feature flags, user preferences. Genuinely needs to be accessible everywhere and persists across page navigations.
Storage: Zustand, Jotai, Redux — or plain React Context for infrequently-changing values.
URL / Navigation State
State encoded in the URL — current route, query parameters, filters, pagination, selected tab. The URL is an underused but powerful state store: it's shareable, bookmarkable, survives a page refresh, and requires no client-side persistence layer.
Storage: URL query params via router (React Router, Next.js). Don't duplicate in a JS store.
The PixelForge Frontend team's state classification guide — a one-page internal document — asks a single diagnostic question for every piece of state they add to the codebase: "If I close this tab and reopen the same URL, should this state still be there?" If yes, it's either server state (fetch it again) or URL state (encode it in the URL). If no, it's either local UI state (keep it in the component) or global client state (put it in Zustand only if multiple components need it). That one question eliminates about 60% of unnecessary store entries before they're ever written.
How React State Drives Rendering — The Mechanism Under the Hood
Every state management decision has a rendering consequence. Before you can reason about where state should live, you need a precise understanding of what React does when state changes — because that mechanism determines which components re-render, how often, and at what cost.
When you call a state setter — setCount(count + 1) — React doesn't immediately update the DOM. It schedules a re-render of the component that owns that state. During the re-render, React calls the component function again with the new state value, generates a new virtual DOM tree, diffs it against the previous tree (a process called reconciliation), and applies only the changed parts to the real DOM. This diff-and-patch process is what makes React efficient — but it's not free, and understanding when it runs unnecessarily is one of the most practical performance skills in front end engineering.
| Concept | What It Does | PixelForge Use Case |
|---|---|---|
| useState | Holds a single value and a setter. Calling the setter schedules a re-render of the owning component and all its children that receive the value as props. | Tooltip open/closed, input draft value, accordion expanded state, sidebar pinned/unpinned. |
| useReducer | Manages state transitions through a pure reducer function. Useful when next state depends on previous state in complex ways, or when multiple sub-values update together. | CanvasEditor undo/redo history — each action dispatches a typed event and the reducer computes the next history stack. |
| React Context | Broadcasts a value to all descendants without passing it through props. Every component that calls useContext re-renders when the context value changes — even if only part of the value changed. |
Active theme token — changes infrequently, consumed by many components, Context is appropriate. |
| useRef | Holds a mutable value that does NOT trigger re-renders when changed. The value persists across renders. Used for DOM references, timers, previous-value tracking, and values that affect behaviour but not appearance. | Tracking the timestamp of the last canvas save without triggering a visual update. |
| Zustand | A minimal global store. Components subscribe to specific slices of the store — only components that subscribe to a changed slice re-render. No Provider required. DevTools supported. | Authenticated user object, active workspace ID, feature flags — global, rarely changes, many consumers. |
| React Query | Manages server state — fetching, caching, deduplicating, background-refetching, and invalidating API data. Provides loading, error, and success states automatically. | Project list, collaborator presence, comment threads — all fetched, cached, and refetched on window focus without manual state management. |
From useState to useReducer — When Simple State Needs Structure
The most common state management mistake is reaching for a global store when useState would have been sufficient — and the second most common is keeping useState when the state complexity has grown past what it can cleanly express. The PixelForge CanvasEditor needed both lessons to learn.
Their canvas element selection system started as a single useState for the selected element ID. Then they needed multi-select. Then they needed selection history for undo. Then they needed to differentiate between a "direct click" selection and a "drag select" selection — because the two had different downstream effects on the properties panel. Five separate useState calls later, the component had 80 lines of state setter choreography. useReducer collapsed it into a single dispatch surface.
// WHAT: PixelForge CanvasEditor — selection state refactored from 5x useState to useReducer
// Before: scattered setters, impossible to track what caused what
// After: typed actions, predictable transitions, testable reducer
const initialState = {
selectedIds: [], // array of element IDs currently selected
selectionMode: 'idle', // 'idle' | 'click' | 'drag'
lastActionAt: null, // timestamp — used by PropertiesPanel to animate in
};
function selectionReducer(state, action) {
switch (action.type) {
case 'SELECT_SINGLE':
return {
...state,
selectedIds: [action.id],
selectionMode: 'click',
lastActionAt: Date.now(),
};
case 'SELECT_MULTI':
return {
...state,
selectedIds: action.ids,
selectionMode: 'drag',
lastActionAt: Date.now(),
};
case 'TOGGLE_ID':
return {
...state,
selectedIds: state.selectedIds.includes(action.id)
? state.selectedIds.filter(id => id !== action.id)
: [...state.selectedIds, action.id],
selectionMode: 'click',
lastActionAt: Date.now(),
};
case 'CLEAR':
return { ...initialState };
default:
return state;
}
}
// Usage inside CanvasEditor component:
const [selection, dispatch] = useReducer(selectionReducer, initialState);
// A click on element 'elem_a3f8':
dispatch({ type: 'SELECT_SINGLE', id: 'elem_a3f8' });
// Shift-click adds to multi-select:
dispatch({ type: 'TOGGLE_ID', id: 'elem_b2c1' });
// Drag selection across 3 elements:
dispatch({ type: 'SELECT_MULTI', ids: ['elem_a3f8', 'elem_b2c1', 'elem_d9e7'] });
What just happened?
The useReducer hook replaced five separate useState calls. Instead of each setter being called independently (with the risk of the state being partially updated if a render occurs between setters), every state transition happens atomically through a single dispatch. The reducer function is a pure function — given the same state and action, it always returns the same result — which means it can be tested completely outside of React with no setup cost. The selectionMode field is the key improvement: it encodes the cause of the selection, not just the result, which lets the PropertiesPanel render a contextually appropriate animation without the CanvasEditor knowing anything about the panel's behaviour.
Try this: Add a UNDO action that restores the previous selectedIds. With useReducer, this is a new case in the switch — no existing logic changes. With five separate useState calls, implementing undo requires coordinating five separate previous-value refs.
Server State — Why It Doesn't Belong in Your Redux Store
Server state is fundamentally different from client state — and treating it as if it were the same produces some of the most painful state management bugs in production. The data doesn't belong to the client. It belongs to the server. The client holds a cache of it. That cache can be stale. It can be invalidated by other users' actions. It can be updated optimistically and then rolled back if the server disagrees.
Managing server state in Redux means manually writing action creators for fetch start, fetch success, and fetch error — for every single API endpoint. It means writing selectors to read cached data. It means writing cache invalidation logic. It means handling deduplication when two components mount simultaneously and both try to fetch the same endpoint. Teams that went all-in on Redux for server state in 2019 spent 2022 migrating to React Query or SWR.
// WHAT: PixelForge ProjectList — server state managed with React Query
// Before: 47 lines of Redux boilerplate (actions, reducer, selector, thunk)
// After: 12 lines, with automatic caching, background refetch, and stale handling
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch all projects for the current workspace
function useProjects(workspaceId) {
return useQuery({
queryKey: ['projects', workspaceId], // cache key — unique per workspace
queryFn: () => api.get(`/workspaces/${workspaceId}/projects`),
staleTime: 30_000, // treat data as fresh for 30 seconds
refetchOnWindowFocus: true, // silently refetch when user returns to tab
});
}
// Archive a project — optimistic update + rollback on error
function useArchiveProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (projectId) => api.patch(`/projects/${projectId}`, { archived: true }),
onMutate: async (projectId) => {
// Optimistically remove the project from the list immediately
await queryClient.cancelQueries({ queryKey: ['projects'] });
const previous = queryClient.getQueryData(['projects']);
queryClient.setQueryData(['projects'], old =>
old.filter(p => p.id !== projectId)
);
return { previous }; // snapshot for rollback
},
onError: (_err, _id, context) => {
// Server rejected the archive — restore the previous list
queryClient.setQueryData(['projects'], context.previous);
},
onSettled: () => {
// Refetch to sync with server truth regardless of success/error
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
});
}
What just happened?
React Query handled five distinct concerns automatically that would each require explicit code in a Redux setup: deduplication (second component mount hits cache, not network), stale detection (30-second window), background refetch (window focus), optimistic updates (instant UI response), and rollback (server rejection restores previous state). The queryKey array is the cache address — changing workspaceId automatically fetches fresh data for the new workspace rather than showing stale data from the previous one. This is fundamentally different from how Redux manages data — React Query treats the server as the source of truth and the client cache as a synchronised snapshot of it.
Try this: Change staleTime: 30_000 to staleTime: Infinity and observe that background refetches stop entirely — useful for data that genuinely never changes during a session (e.g., user account plan tier).
Single Source of Truth — Why It's a Principle, Not a Rule
The "single source of truth" principle is one of the most repeated phrases in state management discussions and one of the most misapplied. Teams hear it and conclude that all state must live in one store. That's not what it means. It means that for any given piece of data, there should be exactly one authoritative location — and every other place that data appears should be derived from or synchronised with that authority.
A project's name can appear in the sidebar, the document title, the breadcrumb, and the share modal simultaneously. None of those are separate sources of truth — they're all reading from the React Query cache for that project. The cache is the single source of truth. The components are views of it. This is correct. What's incorrect is when the sidebar stores the project name in its own local state, the breadcrumb fetches it independently, and the share modal reads it from a Redux slice — three sources, each potentially out of date in a different way at a different time.
| Anti-pattern | What Goes Wrong | Correct Approach |
|---|---|---|
| Storing derived data as state | Storing totalPrice as state when it's quantity * unitPrice. The three values can desynchronise — a UI bug that only shows on edge-case update sequences. |
Derive it: const totalPrice = quantity * unitPrice — computed on every render, always correct, no state needed. |
| Duplicating server data into local state | Fetching a user's name from the API, then storing it in useState(user.name) to "make it editable." If the server updates the name, the local copy doesn't know. If the edit is cancelled, the original value may have been lost. |
Keep server data in React Query. For editing, use a separate form state initialised from the query data. On save, mutate the server and let React Query update its cache. |
| Syncing state between components with useEffect | Using useEffect to copy state from one component to another's state creates a one-frame lag and an infinite-loop risk. Two pieces of state for one value means two render cycles before the UI is correct. |
Lift the state to the common ancestor, or move it to a shared store. Never sync state — share it. |
The PixelForge rule on derived state: If a value can be computed from other state without any asynchronous operations, it should never be stored as state. The PixelForge canvas maintains elements (an array of canvas objects) as its core state. The selection bounding box — the blue rectangle around selected elements — is computed from elements and selectedIds on every render. It is never stored. This means it cannot become stale. It cannot disagree with the elements it represents. And deleting it from the codebase requires deleting zero state management code.
Choosing the Right Tool — A Decision Framework
State management tool selection is one of the most cargo-culted decisions in front end engineering. Teams adopt Redux because their previous company used it. Or they adopt Zustand because they saw a tweet. Or they use React Context for everything because they're avoiding external dependencies. None of these are architectural reasoning — they're social reasoning. The question is always: what problem does this piece of state have, and which tool is the minimum complexity required to solve that problem?
State Tool Decision Flow — The PixelForge Framework
const isSelected = selectedIds.includes(element.id) — not state, just a line of logic.
?tab=settings&filter=archived is a free state store with built-in persistence.
useState. If it's complex with multiple sub-values that transition together, useReducer. Keep it local.
The PixelForge Frontend team ran this decision framework against their existing Zustand store during a 2024 architecture review and found that 31 of their 74 store entries should not have been in the store at all. Fourteen were derived values that could be computed from other state. Nine were server data that belonged in React Query. Eight were URL-serialisable filter and pagination values. Removing them reduced the store to 43 entries, eliminated 12 unnecessary component re-renders on common interactions, and made the remaining store entries significantly easier to reason about — because everything in the store now genuinely needed to be there.
The Overengineering Tax
Redux Toolkit is genuinely excellent for large-scale applications with complex action flows, time-travel debugging requirements, and teams that need strict auditability of state changes. It's also the most frequently adopted tool by teams that would have been better served by three useState calls. The overhead of Redux — slices, reducers, selectors, middleware, DevTools setup — pays for itself at scale. Below that scale, it's a tax on every state change, paid in boilerplate, in lines of code, and in the cognitive load of an engineer who wanted to add a loading spinner and had to write four files to do it.
Quiz
1. The PixelForge project list fetches data from the API and currently stores it in a Redux slice. The team is seeing stale data when users switch workspaces, and cache invalidation requires manually dispatching three actions. Which tool is better suited for this use case and why?
2. The PixelForge canvas needs to know if each element is currently selected in order to render a highlight ring. An engineer proposes adding an isSelected boolean to each element's state object. What is the simpler, more correct approach?
3. The PixelForge canvas selection system has grown to manage selectedIds, selectionMode, and lastActionAt — three values that always update together. An engineer is choosing between multiple useState calls and useReducer. What makes useReducer the better fit here?