State Management
"When would you reach for Redux versus Context versus local state?" is one of the most common system-design-flavored frontend questions there is, because the honest answer reveals whether you've actually shipped something at scale or just followed a tutorial. This lesson builds a Redux-like store from scratch so "how does Redux work internally" stops being a black box, covers useReducer for local, complex state, and settles the controlled-versus-uncontrolled question for form inputs.
Picking a scope, narrowest first
Default to the smallest scope that works, and only lift when there's a concrete reason to.
| Signal | Use |
|---|---|
| Only one component (and maybe its direct children) needs the value | useState / useReducer in that component |
| Several sibling components need it, but it doesn't cross large parts of the tree | Lift to their common parent, pass down as props |
| Truly cross-cutting (theme, auth session, locale) and rarely changes | React Context |
| Complex, frequently-updated, shared across distant parts of the app, needs middleware/devtools/time-travel | Redux / Zustand / Jotai |
| Comes from the server and just needs caching plus revalidation | React Query / SWR, not Redux |
Context is not a state management solution on its own, it's a dependency-injection mechanism for avoiding prop drilling. It has no built-in way to stop unrelated consumers from re-rendering on unrelated value changes: every consumer of a Context re-renders on any change to that Context's value, unless you split contexts or memoize deliberately, and it has no middleware, devtools, or selectors. Redux and Zustand solve a genuinely different problem: efficient, selective subscriptions to slices of a large, frequently-changing store.
| Redux | Context API | Zustand | |
|---|---|---|---|
| Boilerplate | High (actions, reducers, dispatch), much less with Redux Toolkit | Low | Very low |
| Re-render granularity | Fine: useSelector only re-renders on the selected slice |
Coarse: any value change re-renders every consumer | Fine: selector-based |
| DevTools / time-travel | Yes | No | Via middleware |
| Async / middleware | Thunks, Sagas, RTK Query | None built-in | Simple built-in async actions |
| Best for | Large apps, complex cross-cutting state | Rarely-changing app-wide values | Apps that outgrew Context but don't need Redux's ceremony |
Remember: Context is dependency injection, not a state manager. Every consumer re-renders on any value change, with no built-in way to subscribe to just a slice.
Building a store from scratch
This is the core of what createStore actually does: a subscription list, a reducer, and a dispatch that runs the reducer and notifies every subscriber.
type Action = { type: string; payload?: unknown };
type Reducer<S> = (state: S, action: Action) => S;
function createStore<S>(reducer: Reducer<S>, initialState: S) {
let state = initialState;
const listeners = new Set<() => void>();
function getState(): S { return state; }
function dispatch(action: Action): Action {
state = reducer(state, action); // reducer MUST be pure: same inputs → same output, no mutation
listeners.forEach(listener => listener());
return action;
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
return { getState, dispatch, subscribe };
}
type CounterState = { count: number };
function counterReducer(state: CounterState, action: Action): CounterState {
switch (action.type) {
case 'increment': return { count: state.count + 1 }; // new object — never mutate directly
case 'decrement': return { count: state.count - 1 };
default: return state;
}
}
const store = createStore(counterReducer, { count: 0 });
store.subscribe(() => console.log('new state:', store.getState()));
store.dispatch({ type: 'increment' }); // logs: new state: { count: 1 }A React binding for this, roughly what react-redux's useSelector does, uses useSyncExternalStore, the hook purpose-built for subscribing to external, non-React state sources without tearing:
import { useSyncExternalStore } from 'react';
function useStoreSelector<S, T>(store: { getState: () => S; subscribe: (l: () => void) => () => void }, selector: (state: S) => T): T {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()));
}
function Counter() {
const count = useStoreSelector(store, s => s.count);
return <button onClick={() => store.dispatch({ type: 'increment' })}>{count}</button>;
}A reducer is a pure function with the signature (state, action) => newState, and dispatch(action) is the only sanctioned way to trigger one; action is a plain object with a type field and usually a payload. Never mutate state in place, both React and Redux detect change with a reference-equality check, and a mutated-in-place object still has the exact same reference, so the change goes unnoticed. combineReducers lets each reducer own one slice of the overall state tree, which keeps individual reducers small and independently testable, rather than one giant reducer trying to handle every action for the whole app.
Remember: a store is nothing more than state plus a pure reducer plus a set of subscribers.
dispatchruns the reducer, then notifies everyone.
useReducer: the same pattern, scaled down to one component
useReducer is createStore shrunk to a single component, same reducer pattern, no external store. Reach for it over a pile of useState calls when fields update together, or when the "next state" logic reads more clearly as a switch statement than as scattered setters.
type FormState = {
values: { email: string; password: string };
errors: { email?: string; password?: string };
isSubmitting: boolean;
};
type FormAction =
| { type: 'CHANGE_FIELD'; field: 'email' | 'password'; value: string }
| { type: 'SET_ERRORS'; errors: FormState['errors'] }
| { type: 'SUBMIT_START' } | { type: 'SUBMIT_END' };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'CHANGE_FIELD':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: undefined }, // clear that field's error on edit
};
case 'SET_ERRORS': return { ...state, errors: action.errors };
case 'SUBMIT_START': return { ...state, isSubmitting: true };
case 'SUBMIT_END': return { ...state, isSubmitting: false };
default: return state;
}
}
function LoginForm() {
const [state, dispatch] = useReducer(formReducer, {
values: { email: '', password: '' }, errors: {}, isSubmitting: false,
});
const handleChange = (field: 'email' | 'password') => (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'CHANGE_FIELD', field, value: e.target.value });
};
return (
<form>
<input value={state.values.email} onChange={handleChange('email')} />
{state.errors.email && <span>{state.errors.email}</span>}
<button disabled={state.isSubmitting}>Log in</button>
</form>
);
}SET_ERRORS is what a submit handler dispatches after running validation, replacing the whole errors object in one atomic update rather than setting each field's error individually.
The win over five separate useState calls: every field update is one dispatch with clear intent, the reducer is a pure function you can unit-test with zero rendering involved, and related fields update together atomically instead of risking an inconsistent intermediate render where values updated but errors hasn't yet.
Remember: reach for
useReducerover severaluseStatecalls when fields update together, or the "next state" logic reads better as one switch statement.
Controlled vs. uncontrolled inputs
A controlled input has React state as its single source of truth: the input's value comes from state, and every keystroke updates that state through onChange.
function ControlledInput() {
const [value, setValue] = useState("");
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}An uncontrolled input lets the DOM hold the value itself. React only reaches in when it actually needs the value, through a ref, instead of tracking every keystroke.
function UncontrolledInput() {
const ref = useRef<HTMLInputElement>(null);
const submit = () => console.log(ref.current?.value);
return <input ref={ref} defaultValue="" />;
}The trade-off is exactly what it looks like: controlled gives you validation on every keystroke and state that's always current, at the cost of a re-render on every keystroke. Uncontrolled is cheaper, since typing causes zero React re-renders, but you only get the value when you explicitly ask for it, typically on submit. A large form with dozens of fields and no per-keystroke validation is the classic case for going uncontrolled, which is exactly why React Hook Form keeps inputs uncontrolled internally and only re-renders on submit or blur, worth naming directly as the answer to "how would you make a big form scale."
Remember: controlled means React state drives the value, and re-renders on every keystroke. Uncontrolled means the DOM holds it, and you read it through a ref when you need it.
Immutable updates and normalized shape
React and Redux both detect change with reference equality, Object.is/===, not deep equality. Mutating state in place never changes the reference, so nothing downstream ever notices, the UI just silently goes stale.
// WRONG — mutates, reference stays the same, nothing re-renders
state.user.name = 'New Name';
state.items.push(newItem);
// RIGHT — spread creates new references at every level that actually changed
const newState = {
...state,
user: { ...state.user, name: 'New Name' },
items: [...state.items, newItem],
};For deeply nested state, hand-spreading at every level gets unreadable fast, which is exactly the problem Immer (used internally by Redux Toolkit) solves: you write code that looks mutable, and it produces a new immutable tree behind the scenes via a proxy.
Nested, duplicated state, arrays of objects containing arrays of objects, makes a simple update require deep traversal, and risks the same entity existing in two places with two different values.
// Denormalized — hard to update a single comment, author data duplicated across posts
type DenormalizedState = {
posts: Array<{
id: string; title: string;
author: { id: string; name: string };
comments: Array<{ id: string; text: string; author: { id: string; name: string } }>;
}>;
};Normalize it the way a relational database would: flat lookup tables keyed by ID, plus ID arrays for ordering.
// Normalized — O(1) lookup and update by ID, single source of truth per entity
type NormalizedState = {
posts: { byId: Record<string, { id: string; title: string; authorId: string; commentIds: string[] }>; allIds: string[] };
comments: { byId: Record<string, { id: string; text: string; authorId: string }>; allIds: string[] };
users: { byId: Record<string, { id: string; name: string }>; allIds: string[] };
};
function editComment(state: NormalizedState, commentId: string, text: string): NormalizedState {
return {
...state,
comments: { ...state.comments, byId: { ...state.comments.byId, [commentId]: { ...state.comments.byId[commentId], text } } },
};
}Editing one comment becomes a targeted, cheap operation, exactly one entry in one lookup table changes, instead of hunting through a nested array of posts to find the right comment buried three levels deep.
Remember: denormalized, duplicated state risks the same entity existing in two places with two different values. Normalize it into flat, keyed lookup tables instead.