Learning Guides
Menu

State Management Patterns

11 min readFrontend 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/Zustand

Local 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

PatternBest ForAvoid When
useStateSimple, component-local stateMany components need it
useReducerComplex local state with actionsSimple toggle/counter
Lift StateSiblings sharing stateDeep nesting
ContextTheme, auth, localeFrequently changing data
Redux/ZustandLarge app-wide stateSmall apps
State MachinesComplex flows, many statesSimple CRUD
React QueryServer/async dataPurely client state
URL StateShareable, bookmarkable stateSensitive data

Key Principles:

  1. Start with the simplest solution (usually useState)
  2. Lift state only when needed
  3. Separate server state from UI state
  4. Use the right tool for the job
  5. Optimize for readability, then performance