State Management Patterns

Choose and implement the right state management pattern for your frontend application. Covers local state, lifted state, context, external stores (Redux, Zustand, Jotai), server state management, and when each approach makes sense.

State management is the discipline of coordinating data across components, views, and user interactions in a frontend application. It matters when a user’s actions — form inputs, navigation, asynchronous responses, or real-time updates — must be reflected consistently and efficiently across the UI. This page details the patterns, techniques, and trade-offs for managing state, from simple local state to distributed, server-synchronized state, with a focus on clarity, correctness, and maintainability.

Core Patterns for State Management

Local State: Component-Level Updates

When a component needs to track internal values that change in response to user input or internal logic, local state is sufficient.

function SearchInput() {
  const [query, setQuery] = useState('');
  const [isSearching, setIsSearching] = useState(false);

  const handleInputChange = (event) => {
    setQuery(event.target.value);
    setIsSearching(true);
    setTimeout(() => setIsSearching(false), 300);
  };

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleInputChange}
        placeholder="Search..."
      />
      {isSearching && <span className="spinner">Loading...</span>}
    </div>
  );
}

Common pitfalls:

> Failure mode: A form with five fields, each using useState, but setField calls are not batched. When a user types into a field, the component re-renders five times, once for each setX call.

Solution: Combine multiple useState updates into a single useReducer or use useCallback to memoize handler functions.

const [form, setForm] = useReducer((state, action) => {
  switch (action.type) {
    case 'update':
      return { ...state, [action.field]: action.value };
    case 'reset':
      return { name: '', email: '', notes: '' };
    default:
      return state;
  }
}, { name: '', email: '', notes: '' });

const updateField = useCallback((field, value) => {
  setForm({ type: 'update', field, value });
}, []);

Global State: Cross-Component Consistency

When multiple components need to access or modify the same data, global state is necessary.

Shared State via Context

Use Context for state that flows top-down through the component tree.

const UserContext = createContext();

function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(setUser)
      .finally(() => setLoading(false));
  }, []);

  return (
    <UserContext.Provider value={{ user, loading, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

function UserProfile() {
  const { user, loading, setUser } = useContext(UserContext);

  if (loading) return <div>Loading user...</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <button onClick={() => setUser(null)}>Logout</button>
    </div>
  );
}

Key failure modes:

Solution: Memoize the provider value using useMemo.

const value = useMemo(() => ({ user, loading, setUser }), [user, loading, setUser]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;

Centralized State Store

For more complex state with actions, side effects, and derived data, use a centralized store.

const initialState = { todos: [], filter: 'all' };

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.payload.text, completed: false }]
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    case 'SET_FILTER':
      return { ...state, filter: action.payload.filter };
    default:
      return state;
  }
}

Common configuration keys:

Failure modes:

> Error text: TypeError: Cannot read property 'map' of undefined — likely because the reducer returned undefined for an unknown action type.

Solution: Use immer to write mutable reducers that produce immutable results.

import produce from 'immer';

function todoReducer(state, action) {
  return produce(state, draft => {
    switch (action.type) {
      case 'ADD_TODO':
        draft.todos.push({
          id: Date.now(),
          text: action.payload.text,
          completed: false
        });
        break;
      case 'TOGGLE_TODO':
        const todo = draft.todos.find(t => t.id === action.payload.id);
        todo.completed = !todo.completed;
        break;
      default:
        return state;
    }
  });
}

Synchronized State: Server-Connected Data

When state depends on server data, it must be synchronized across client and server.

Server State with Fetching and Caching

Components fetch data and update state when responses arrive.

function PostList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/posts')
      .then(res => {
        if (!res.ok) throw new Error('Failed to load posts');
        return res.json();
      })
      .then(setPosts)
      .catch(setError)
      .finally(() => setLoading(false));

    return () => {
      // cleanup: cancel fetch if component unmounts
      // (via AbortController or fetch with signal)
    };
  }, []);

  if (loading) return <div>Loading posts...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Failure modes:

Solution: Implement client-side caching with fetch and cache-control headers.

async function fetchWithCache(url, options = {}) {
  const cacheKey = `cache-${url}`;
  const cached = localStorage.getItem(cacheKey);
  const now = Date.now();

  if (cached) {
    const { data, timestamp } = JSON.parse(cached);
    const age = now - timestamp;

    if (age < 30_000) { // 30 seconds
      return data;
    }
  }

  const response = await fetch(url, options);
  const data = await response.json();
  localStorage.setItem(cacheKey, JSON.stringify({ data, timestamp: now }));

  return data;
}

Real-Time State: Live Updates

For applications requiring live updates — chat, dashboards, collaborative editors — use WebSocket or polling.

function ChatRoom() {
  const [messages, setMessages] = useState([]);
  const [ws, setWs] = useState(null);

  useEffect(() => {
    const socket = new WebSocket('wss://chat.example.com/ws');

    socket.onmessage = event => {
      const message = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    };

    socket.onopen = () => {
      console.log('Connected to chat server');
      socket.send(JSON.stringify({ type: 'join', userId: 123 }));
    };

    socket.onerror = error => {
      console.error('WebSocket error:', error);
    };

    socket.onclose = () => {
      console.log('WebSocket closed');
      // Reconnect after 3 seconds
      setTimeout(() => {
        setWs(new WebSocket('wss://chat.example.com/ws'));
      }, 3000);
    };

    setWs(socket);

    return () => socket.close();
  }, []);

  return (
    <div>
      <ul>
        {messages.map(msg => (
          <li key={msg.id}>{msg.text}</li>
        ))}
      </ul>
    </div>
  );
}

Failure modes:

Solution: Use a messageId field and a lastReceivedId tracking mechanism.

// On client: track the latest message ID received
const [lastReceivedId, setLastReceivedId] = useState(0);

socket.onmessage = event => {
  const message = JSON.parse(event.data);
  if (message.id > lastReceivedId) {
    setMessages(prev => [...prev, message]);
    setLastReceivedId(message.id);
  }
};

Derived State: Computed Values

When state depends on other state, compute derived values efficiently.

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');

  const filteredTodos = todos.filter(todo => {
    switch (filter) {
      case 'active':
        return !todo.completed;
      case 'completed':
        return todo.completed;
      default:
        return true;
    }
  });

  const completedCount = todos.filter(t => t.completed).length;
  const totalCount = todos.length;
  const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;

  return (
    <div>
      <div>
        <span>Progress: {progress.toFixed(1)}%</span>
        <span>({completedCount}/{totalCount})</span>
      </div>
      <ul>
        {filteredTodos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span>{todo.text}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

Failure mode: filteredTodos is recalculated on every render, even when todos or filter haven’t changed.

Solution: Use useMemo to memoize derived state.

const filteredTodos = useMemo(() => {
  return todos.filter(todo => {
    switch (filter) {
      case 'active':
        return !todo.completed;
      case 'completed':
        return todo.completed;
      default:
        return true;
    }
  });
}, [todos, filter]);

State Management in Practice

> Common error: Cannot read property 'map' of undefined — todos is undefined because useEffect runs before setTodos has been called.

> Best practice: Use useReducer with useMemo for state that is updated frequently and has complex logic.

> Advanced technique: Implement a useStore hook that abstracts access to a global store.

function useStore(selector) {
  const [state, dispatch] = useContext(StoreContext);

  const selectedState = useMemo(() => selector(state), [state, selector]);
  return [selectedState, dispatch];
}

This pattern enables predictable, testable, and scalable state management across all levels of a frontend application.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.