State Management Architecture
Every bug you've spent more than 30 minutes on was probably a state bug. Stale closures, race conditions, impossible UI states, data that's out of sync β all of these are state management failures. Architects don't just pick a library; they design a state topology.
The State Topology Spectrum
βββββββββββ ββββββββββββ ββββββββββββββ ββββββββββββββββ
β Local ββββΆβ Lifted ββββΆβ Global ββββΆβ Server Cache β
β State β β State β β State β β β
β β β β β β β β
β useState β β parent β β Zustand β β React Query β
β useRef β β passes β β Redux TK β β SWR β
β β β down β β Jotai β β Apollo β
βββββββββββ ββββββββββββ ββββββββββββββ ββββββββββββββββ
β² β²
β START HERE β
β (move right only when needed) β
βββββββββββββββββββββββββββββββββββββββββββββββββThe golden rule: state should live as close to where it's consumed as possible. Every layer you move rightward adds complexity, indirection, and potential for bugs.
The Five Layers of Frontend State
Not all state is the same. Treating it as one blob is the root cause of most state management pain.
Layer 1: UI State
Ephemeral, component-scoped state that affects presentation only.
const [isOpen, setIsOpen] = useState(false); // dropdown open
const [activeTab, setActiveTab] = useState('info'); // tab selection
const [isHovered, setIsHovered] = useState(false); // hover effectWhere it lives: useState, useReducer β local to the component. Never globalize this.
Layer 2: Form State
Input values, validation, dirty tracking, submission status.
const form = useForm({
defaultValues: { name: '', email: '' },
resolver: zodResolver(schema),
});Where it lives: Form library (React Hook Form, Formik) or local useReducer. Form state is inherently local to the form β globalizing it causes needless re-renders across the app.
Layer 3: URL State
Current route, search params, filters, pagination cursors.
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) ?? 1;
const sort = searchParams.get('sort') ?? 'newest';Where it lives: The URL. This is the most underused state container. URL state is shareable (copy-paste a link), bookmarkable, and survives refreshes for free.
Layer 4: Server Cache
Data fetched from APIs. This is NOT application state β it's a cache of someone else's state (the server's).
const { data: posts, isLoading } = useQuery({
queryKey: ['posts', { page, sort }],
queryFn: () => fetchPosts({ page, sort }),
staleTime: 30_000,
});Where it lives: React Query, SWR, Apollo Client. These tools handle caching, deduplication, background refetching, and cache invalidation β things you'd get wrong building from scratch.
Layer 5: Global Application State
Truly shared state that multiple distant components need: auth user, theme, feature flags, notification count.
const useAuthStore = create<AuthState>((set) => ({
user: null,
token: null,
login: async (credentials) => {
const { user, token } = await authApi.login(credentials);
set({ user, token });
},
logout: () => set({ user: null, token: null }),
}));Where it lives: Zustand, Redux Toolkit, Jotai, or React Context (for low-frequency updates).
The Layer Map
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β State Layers β
β β
β ββββββββββββββ Changes every ββββββββββββββββββ β
β β UI State β interaction βββββΆβ useState β β
β ββββββββββββββ ββββββββββββββββββ β
β ββββββββββββββ Changes per ββββββββββββββββββ β
β β Form State β keystroke βββββΆβ React Hook Formβ β
β ββββββββββββββ ββββββββββββββββββ β
β ββββββββββββββ Changes per ββββββββββββββββββ β
β β URL State β navigation βββββΆβ URL / Router β β
β ββββββββββββββ ββββββββββββββββββ β
β ββββββββββββββ Changes on ββββββββββββββββββ β
β βServer Cache β fetch/mutate ββββΆβ React Query β β
β ββββββββββββββ ββββββββββββββββββ β
β ββββββββββββββ Changes ββββββββββββββββββ β
β β Global App β infrequently ββββΆβ Zustand/Redux β β
β ββββββββββββββ ββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββClient State vs Server State
This distinction is the most important mental model shift in modern frontend architecture.
| Characteristic | Client State | Server State |
|---|---|---|
| Owner | The browser | The server/database |
| Persistence | Ephemeral | Persisted |
| Synchronization | Not needed | Must sync with source of truth |
| Staleness | Doesn't go stale | Goes stale immediately |
| Shared | Single user | Multiple users |
| Example | Sidebar open, modal visible | User profile, post list |
The mistake: putting server data into Redux. You end up manually handling loading states, errors, caching, deduplication, background refresh, and cache invalidation. React Query handles all of this.
β Redux with server data:
dispatch(fetchPostsRequest())
dispatch(fetchPostsSuccess(data))
dispatch(fetchPostsFailure(error))
+ manual cache invalidation
+ manual deduplication
+ manual background refresh
β
React Query:
useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
(loading, error, caching, dedup, refresh β all handled)State Machines for Complex Flows
When state has well-defined transitions and impossible states should be impossible, use a state machine.
Example: Payment Flow
ββββββββββββ
β idle β
ββββββ¬ββββββ
β SUBMIT
ββββββΌββββββ
βββββββvalidatingβββββββ
β ββββββββββββ β
VALID INVALID
β β
ββββββΌββββββ ββββββΌββββββ
βprocessingβ β error βββββ RETRY βββΆ idle
ββββββ¬ββββββ ββββββββββββ
β
βββββββ΄βββββββ
SUCCESS FAILURE
β β
βββββΌββββ ββββββΌββββββ
βsuccess β β failed βββββ RETRY βββΆ idle
ββββββββββ ββββββββββββimport { createMachine, assign } from 'xstate';
const paymentMachine = createMachine({
id: 'payment',
initial: 'idle',
context: { error: null, retries: 0 },
states: {
idle: {
on: { SUBMIT: 'validating' },
},
validating: {
invoke: {
src: 'validatePayment',
onDone: 'processing',
onError: { target: 'error', actions: assign({ error: (_, e) => e.data }) },
},
},
processing: {
invoke: {
src: 'processPayment',
onDone: 'success',
onError: { target: 'failed', actions: assign({ error: (_, e) => e.data }) },
},
},
success: { type: 'final' },
failed: {
on: {
RETRY: {
target: 'idle',
guard: ({ context }) => context.retries < 3,
actions: assign({ retries: ({ context }) => context.retries + 1 }),
},
},
},
error: {
on: { RETRY: 'idle' },
},
},
});State machines make impossible states unrepresentable. You can't be in processing and error simultaneously. You can't transition from success to validating. The machine enforces these rules at the type level.
Optimistic Updates
Show the result of an action immediately, before the server confirms it.
const likeMutation = useMutation({
mutationFn: (postId: string) => api.likePost(postId),
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previous = queryClient.getQueryData(['posts']);
queryClient.setQueryData(['posts'], (old: Post[]) =>
old.map(post =>
post.id === postId
? { ...post, liked: true, likeCount: post.likeCount + 1 }
: post
)
);
return { previous };
},
onError: (_err, _postId, context) => {
queryClient.setQueryData(['posts'], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});The pattern: Snapshot β Optimistic update β On error rollback β On settle revalidate.
Cache Invalidation Strategies
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Cache Invalidation β
β β
β Time-based β staleTime: 30s, refetch every 60s β
β Event-based β Invalidate on mutation success β
β Tag-based β Invalidate all queries with tag β
β Manual β queryClient.invalidateQueries() β
β Pessimistic β Wait for server, then update cache β
β Optimistic β Update cache immediately β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββReact Query's Invalidation Model
queryClient.invalidateQueries({ queryKey: ['posts'] });
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: false,
});
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'user' && query.queryKey[1] === userId,
});State Synchronization
Cross-Tab Synchronization
When a user logs out in one tab, all tabs should reflect it:
const channel = new BroadcastChannel('auth-sync');
channel.onmessage = (event) => {
if (event.data.type === 'LOGOUT') {
authStore.getState().logout();
router.push('/login');
}
};
function logout() {
authStore.getState().logout();
channel.postMessage({ type: 'LOGOUT' });
}Offline Synchronization
ββββββββββββ ββββββββββββ ββββββββββββ
β Online βββββββββΆβ Offline βββββββββΆβ Sync β
β β lost β β regain β β
β Read/ β conn β Read fromβ conn β Push β
β Write to β β local DB β β queued β
β server β β Queue β β writes β
β β β writes β β Resolve β
β β β β β conflictsβ
ββββββββββββ ββββββββββββ ββββββββββββNormalized vs Denormalized State
Denormalized (duplicated data)
{
"posts": [
{ "id": "p1", "author": { "id": "u1", "name": "Alice", "avatar": "..." } },
{ "id": "p2", "author": { "id": "u1", "name": "Alice", "avatar": "..." } }
]
}Problem: Update Alice's avatar β find and update every post by Alice.
Normalized (single source of truth)
{
"entities": {
"users": { "u1": { "id": "u1", "name": "Alice", "avatar": "..." } },
"posts": {
"p1": { "id": "p1", "authorId": "u1" },
"p2": { "id": "p2", "authorId": "u1" }
}
},
"feedOrder": ["p1", "p2"]
}Update Alice's avatar β one update to entities.users.u1. All posts reference it by ID.
When to normalize: large datasets with shared entities (social apps, dashboards). When to skip: small datasets, server-driven UIs, pages without cross-entity updates.
When NOT to Use Global State
Global state is overused. You don't need it when:
- Only one component reads it β
useState - Only parent-child share it β props
- It's server data β React Query
- It's URL-representable β
useSearchParams - It's form data β form library
- It changes on every render β
useRef
State Management Selection Matrix
| Criteria | Context | Zustand | Redux TK | Jotai | React Query |
|---|---|---|---|---|---|
| Learning curve | Low | Low | Medium | Low | Medium |
| Boilerplate | Low | Very low | Medium | Very low | Low |
| DevTools | None | Basic | Excellent | Basic | Excellent |
| Re-render control | Poor (all consumers) | Good (selectors) | Good (selectors) | Excellent (atomic) | N/A |
| Server state | Don't | Don't | Don't (use RTK Query) | Don't | Yes |
| Bundle size | 0 KB | ~1 KB | ~11 KB | ~2 KB | ~13 KB |
| Middleware | None | Simple | Rich ecosystem | None | Built-in |
| Best for | Theme, locale | Most client state | Complex client state | Fine-grained reactivity | Server cache |
Decision Flowchart
Is it server data?
ββ YES β React Query / SWR / RTK Query
ββ NO β Is it URL-representable?
ββ YES β URL search params
ββ NO β Is it form data?
ββ YES β React Hook Form / local reducer
ββ NO β How many components need it?
ββ 1 β useState
ββ Parent + children β props / composition
ββ Distant components β Zustand (default)
ββ Need middleware/devtools/ecosystem? β Redux TK
ββ Need atomic fine-grained updates? β JotaiArchitecture Example: E-Commerce Cart
βββββββββββββββββββββββββββββββββββββββββββββββ
β State Architecture β
β β
β URL State β
β ββ /products?category=shoes&sort=price β
β β
β Server Cache (React Query) β
β ββ products list, product details, reviews β
β β
β Global State (Zustand) β
β ββ cart items, auth user, theme β
β β
β Form State (React Hook Form) β
β ββ checkout form, search input β
β β
β UI State (useState) β
β ββ mobile menu open, image zoom, tooltips β
βββββββββββββββββββββββββββββββββββββββββββββββEach layer uses the right tool. No single library tries to do everything. This is what a well-architected state topology looks like β and it's exactly what interviewers want to hear you articulate.