State Management Patterns
11 min read•Frontend Patterns & Concepts
State Management Patterns
State management is the backbone of interactive applications. Choosing the right pattern—and knowing when to use each—separates maintainable applications from tangled messes.
The State Management Spectrum
PLAINTEXT
Simple ←────────────────────────────────→ Complex
Local State → Lifting State → Context → State Machines → Global Stores
useState props drilling useContext XState Redux/ZustandLocal Component State
Start here. Most state doesn't need to be global.
JAVASCRIPT
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
}
// Complex local state with useReducer
function TodoList() {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<div>
<AddTodo onAdd={(text) => dispatch({ type: "ADD", text })} />
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => dispatch({ type: "TOGGLE", id: todo.id })}
onDelete={() => dispatch({ type: "DELETE", id: todo.id })}
/>
))}
</div>
);
}
function todoReducer(state, action) {
switch (action.type) {
case "ADD":
return [
...state,
{
id: Date.now(),
text: action.text,
completed: false,
},
];
case "TOGGLE":
return state.map((todo) =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo,
);
case "DELETE":
return state.filter((todo) => todo.id !== action.id);
default:
return state;
}
}Lifting State Up
When siblings need shared state, lift it to their common parent.
JAVASCRIPT
// ❌ Before: Disconnected components
function TemperatureInput() {
const [temp, setTemp] = useState("");
return <input value={temp} onChange={(e) => setTemp(e.target.value)} />;
}
// ✅ After: Lifted state
function TemperatureConverter() {
const [celsius, setCelsius] = useState("");
const fahrenheit = celsius
? ((parseFloat(celsius) * 9) / 5 + 32).toFixed(1)
: "";
return (
<div>
<TemperatureInput label="Celsius" value={celsius} onChange={setCelsius} />
<TemperatureInput
label="Fahrenheit"
value={fahrenheit}
onChange={(f) =>
setCelsius((((parseFloat(f) - 32) * 5) / 9).toFixed(1))
}
/>
</div>
);
}
function TemperatureInput({ label, value, onChange }) {
return (
<label>
{label}:
<input value={value} onChange={(e) => onChange(e.target.value)} />
</label>
);
}Props Drilling Problem
When state needs to travel many levels, props drilling becomes unwieldy:
JAVASCRIPT
// Props drilling through 4 levels
function App() {
const [user, setUser] = useState(null);
return <Dashboard user={user} setUser={setUser} />;
}
function Dashboard({ user, setUser }) {
return <Sidebar user={user} setUser={setUser} />;
}
function Sidebar({ user, setUser }) {
return <UserMenu user={user} setUser={setUser} />;
}
function UserMenu({ user, setUser }) {
return <span>{user?.name}</span>;
}React Context
Context solves props drilling for truly global state.
Creating Context
JAVASCRIPT
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
} from "react";
// 1. Create context with default value
const AuthContext = createContext(null);
// 2. Create provider component
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const login = useCallback(async (credentials) => {
setLoading(true);
try {
const user = await authApi.login(credentials);
setUser(user);
return user;
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
await authApi.logout();
setUser(null);
}, []);
// Memoize to prevent unnecessary re-renders
const value = useMemo(
() => ({
user,
loading,
login,
logout,
isAuthenticated: !!user,
}),
[user, loading, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// 3. Create custom hook for consuming
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
// 4. Usage
function App() {
return (
<AuthProvider>
<Router>
<AppContent />
</Router>
</AuthProvider>
);
}
function UserMenu() {
const { user, logout, isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <LoginButton />;
}
return (
<div>
<span>{user.name}</span>
<button onClick={logout}>Logout</button>
</div>
);
}Context Performance Optimization
JAVASCRIPT
// ❌ Problem: All consumers re-render on any change
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const [notifications, setNotifications] = useState([]);
// Every state change re-renders ALL consumers!
return (
<AppContext.Provider
value={{
user,
setUser,
theme,
setTheme,
notifications,
setNotifications,
}}
>
{children}
</AppContext.Provider>
);
}
// ✅ Solution 1: Split contexts
const UserContext = createContext();
const ThemeContext = createContext();
const NotificationContext = createContext();
function AppProvider({ children }) {
return (
<UserProvider>
<ThemeProvider>
<NotificationProvider>{children}</NotificationProvider>
</ThemeProvider>
</UserProvider>
);
}
// ✅ Solution 2: Separate state and dispatch
const StateContext = createContext();
const DispatchContext = createContext();
function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
// Components that only dispatch don't re-render on state changes
function AddButton() {
const dispatch = useContext(DispatchContext);
return <button onClick={() => dispatch({ type: "ADD" })}>Add</button>;
}
// ✅ Solution 3: Selector pattern with useSyncExternalStore
function useAppSelector(selector) {
const state = useContext(StateContext);
return useMemo(() => selector(state), [state, selector]);
}
// Only re-renders when selected value changes
function UserName() {
const name = useAppSelector((state) => state.user?.name);
return <span>{name}</span>;
}The Flux Pattern
Unidirectional data flow: Action → Dispatcher → Store → View
JAVASCRIPT
// Simple Flux-like implementation
class Store {
constructor(reducer, initialState) {
this.state = initialState;
this.reducer = reducer;
this.listeners = new Set();
}
getState() {
return this.state;
}
dispatch(action) {
this.state = this.reducer(this.state, action);
this.listeners.forEach((listener) => listener(this.state));
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
// Usage
const store = new Store(todoReducer, { todos: [] });
store.subscribe((state) => {
console.log("State updated:", state);
renderApp(state);
});
store.dispatch({ type: "ADD_TODO", text: "Learn Flux" });Redux Pattern (Simplified)
JAVASCRIPT
import { configureStore, createSlice } from "@reduxjs/toolkit";
// Create a slice (reducer + actions)
const todosSlice = createSlice({
name: "todos",
initialState: [],
reducers: {
add: (state, action) => {
state.push({
id: Date.now(),
text: action.payload,
completed: false,
});
},
toggle: (state, action) => {
const todo = state.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
remove: (state, action) => {
return state.filter((t) => t.id !== action.payload);
},
},
});
// Create store
const store = configureStore({
reducer: {
todos: todosSlice.reducer,
},
});
// Export actions and selectors
export const { add, toggle, remove } = todosSlice.actions;
export const selectTodos = (state) => state.todos;
export const selectCompletedCount = (state) =>
state.todos.filter((t) => t.completed).length;
// React integration
import { Provider, useSelector, useDispatch } from "react-redux";
function App() {
return (
<Provider store={store}>
<TodoApp />
</Provider>
);
}
function TodoList() {
const todos = useSelector(selectTodos);
const dispatch = useDispatch();
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span onClick={() => dispatch(toggle(todo.id))}>{todo.text}</span>
<button onClick={() => dispatch(remove(todo.id))}>×</button>
</li>
))}
</ul>
);
}Zustand: Minimal Global State
JAVASCRIPT
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Create store
const useStore = create(
persist(
(set, get) => ({
// State
todos: [],
filter: "all",
// Actions
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: Date.now(), text, completed: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
})),
removeTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
setFilter: (filter) => set({ filter }),
// Computed values (using get)
filteredTodos: () => {
const { todos, filter } = get();
switch (filter) {
case "completed":
return todos.filter((t) => t.completed);
case "active":
return todos.filter((t) => !t.completed);
default:
return todos;
}
},
}),
{ name: "todos-storage" }, // Persist to localStorage
),
);
// Usage - no Provider needed!
function TodoList() {
const todos = useStore((state) => state.filteredTodos());
const toggleTodo = useStore((state) => state.toggleTodo);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.text}
</li>
))}
</ul>
);
}
// Subscribe to specific slice
function TodoCount() {
const count = useStore((state) => state.todos.length);
return <span>{count} todos</span>;
}State Machines with XState
For complex state logic with well-defined transitions.
JAVASCRIPT
import { createMachine, assign } from "xstate";
import { useMachine } from "@xstate/react";
// Define the machine
const fetchMachine = createMachine({
id: "fetch",
initial: "idle",
context: {
data: null,
error: null,
retries: 0,
},
states: {
idle: {
on: { FETCH: "loading" },
},
loading: {
invoke: {
src: "fetchData",
onDone: {
target: "success",
actions: assign({ data: (_, event) => event.data }),
},
onError: {
target: "failure",
actions: assign({ error: (_, event) => event.data }),
},
},
},
success: {
on: { REFRESH: "loading" },
},
failure: {
on: {
RETRY: {
target: "loading",
actions: assign({ retries: (ctx) => ctx.retries + 1 }),
cond: (ctx) => ctx.retries < 3,
},
},
},
},
});
// Usage
function DataFetcher({ url }) {
const [state, send] = useMachine(fetchMachine, {
services: {
fetchData: () => fetch(url).then((r) => r.json()),
},
});
return (
<div>
{state.matches("idle") && (
<button onClick={() => send("FETCH")}>Load Data</button>
)}
{state.matches("loading") && <Spinner />}
{state.matches("success") && (
<div>
<pre>{JSON.stringify(state.context.data, null, 2)}</pre>
<button onClick={() => send("REFRESH")}>Refresh</button>
</div>
)}
{state.matches("failure") && (
<div>
<p>Error: {state.context.error.message}</p>
{state.context.retries < 3 && (
<button onClick={() => send("RETRY")}>
Retry ({3 - state.context.retries} left)
</button>
)}
</div>
)}
</div>
);
}Form State Machine
JAVASCRIPT
const formMachine = createMachine({
id: "form",
initial: "editing",
context: {
values: {},
errors: {},
touched: {},
},
states: {
editing: {
on: {
CHANGE: {
actions: assign({
values: (ctx, e) => ({ ...ctx.values, [e.field]: e.value }),
touched: (ctx, e) => ({ ...ctx.touched, [e.field]: true }),
}),
},
BLUR: {
actions: "validateField",
},
SUBMIT: "validating",
},
},
validating: {
invoke: {
src: "validate",
onDone: [
{
target: "submitting",
cond: (_, e) => Object.keys(e.data).length === 0,
},
{ target: "editing", actions: assign({ errors: (_, e) => e.data }) },
],
},
},
submitting: {
invoke: {
src: "submit",
onDone: "success",
onError: {
target: "editing",
actions: assign({ errors: (_, e) => ({ form: e.data.message }) }),
},
},
},
success: {
type: "final",
},
},
});Server State with React Query / TanStack Query
Server state is fundamentally different from client state:
JAVASCRIPT
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// Fetching data
function UserProfile({ userId }) {
const {
data: user,
isLoading,
error,
} = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
cacheTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
});
if (isLoading) return <Skeleton />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
// Mutations with optimistic updates
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const toggleMutation = useMutation({
mutationFn: (id) => api.toggleTodo(id),
// Optimistic update
onMutate: async (id) => {
// Cancel outgoing refetches
await queryClient.cancelQueries(["todos"]);
// Snapshot previous value
const previousTodos = queryClient.getQueryData(["todos"]);
// Optimistically update
queryClient.setQueryData(["todos"], (old) =>
old.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)),
);
// Return context for rollback
return { previousTodos };
},
// Rollback on error
onError: (err, id, context) => {
queryClient.setQueryData(["todos"], context.previousTodos);
},
// Refetch after success or error
onSettled: () => {
queryClient.invalidateQueries(["todos"]);
},
});
return <li onClick={() => toggleMutation.mutate(todo.id)}>{todo.text}</li>;
}URL as State
Some state belongs in the URL for shareability and back button support:
JAVASCRIPT
import { useSearchParams } from "react-router-dom";
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get("category") || "all";
const sort = searchParams.get("sort") || "newest";
const page = parseInt(searchParams.get("page") || "1");
const updateFilters = (updates) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
Object.entries(updates).forEach(([key, value]) => {
if (value) {
next.set(key, value);
} else {
next.delete(key);
}
});
return next;
});
};
return (
<div>
<Filters category={category} sort={sort} onChange={updateFilters} />
<Products category={category} sort={sort} page={page} />
<Pagination page={page} onChange={(p) => updateFilters({ page: p })} />
</div>
);
}Choosing the Right Pattern
PLAINTEXT
┌─────────────────────────────────────────────────────────────┐
│ "Where should this state live?" │
├─────────────────────────────────────────────────────────────┤
│ │
│ Is it from an API? ──────► React Query / SWR │
│ │ │
│ ▼ No │
│ Should URL reflect it? ──► URL State (searchParams) │
│ │ │
│ ▼ No │
│ Does only one component use it? ──► useState │
│ │ │
│ ▼ No │
│ Do siblings share it? ──► Lift to parent │
│ │ │
│ ▼ No │
│ Is it deeply nested? ──► Context │
│ │ │
│ ▼ No │
│ Is it complex with many transitions? ──► State Machine │
│ │ │
│ ▼ No │
│ Is it app-wide and frequently updated? ──► Zustand/Redux │
│ │
└─────────────────────────────────────────────────────────────┘Summary
| Pattern | Best For | Avoid When |
|---|---|---|
| useState | Simple, component-local state | Many components need it |
| useReducer | Complex local state with actions | Simple toggle/counter |
| Lift State | Siblings sharing state | Deep nesting |
| Context | Theme, auth, locale | Frequently changing data |
| Redux/Zustand | Large app-wide state | Small apps |
| State Machines | Complex flows, many states | Simple CRUD |
| React Query | Server/async data | Purely client state |
| URL State | Shareable, bookmarkable state | Sensitive data |
Key Principles:
- Start with the simplest solution (usually useState)
- Lift state only when needed
- Separate server state from UI state
- Use the right tool for the job
- Optimize for readability, then performance