Quiz

How do you decide between using React state, context, and external state managers?

Topics
React

TL;DR

Match the tool to the kind of state. Use useState/useReducer for local component state, and lift state up before reaching for anything heavier. Context transports a value through a subtree; it does not provide storage, update logic, or fine-grained subscriptions by itself. Reach for a client-state library such as Zustand, Jotai, or Redux Toolkit when many unrelated components need shared state and selector-based subscriptions or store tooling. Treat server state separately: a framework data layer or a library such as TanStack Query, SWR, or RTK Query can add caching, refetching, and invalidation when the application needs them.


Deciding between React state, context, and external state managers

Choose the smallest state mechanism that matches the value's ownership, update frequency, subscriber shape, and persistence needs.

React state (useState and useReducer)

React's built-in hooks cover the majority of real-world state needs. Start here and only escalate when you have a concrete problem to solve.

  • Use useState for simple, independent values (a toggle, an input value, a counter).
  • Use useReducer when several state fields update together, when the next state depends on the previous state in complex ways, or when you want all transitions described in one place. It also pairs well with Context for app-scoped state that doesn't change often.

When to use React state

Keep state local when its consumers and update logic remain within one cohesive part of the tree.

  • The state is only relevant to one component or a small subtree
  • The state can be lifted to the closest common ancestor without prop-drilling getting painful
  • You don't need cross-tree access, persistence, or devtools

Example

This counter needs no sharing mechanism because the state belongs to one component:

import { useState, useReducer } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
}
function reducer(state, action) {
switch (action.type) {
case 'add':
return { items: [...state.items, action.item] };
case 'remove':
return { items: state.items.filter((i) => i.id !== action.id) };
default:
return state;
}
}
function Cart() {
const [state, dispatch] = useReducer(reducer, {
items: [{ id: 'book', name: 'Book' }],
});
return (
<ul>
{state.items.map((item) => (
<li key={item.id}>
{item.name}{' '}
<button onClick={() => dispatch({ type: 'remove', id: item.id })}>
Remove
</button>
</li>
))}
</ul>
);
}

React context

Context is a value-transport mechanism rather than a complete state-management solution. It lets descendants read a value without prop-drilling, but it does not supply storage or update logic. Consumers subscribed to a context re-render when the closest provider receives a different value according to Object.is; components that do not read that context are not updated for that reason. Putting unrelated, frequently changing values into one context can therefore cause unnecessary consumer renders.

Context works well for values such as theme, locale, current user, and feature flags. Pairing it with useState or useReducer can form a perfectly reasonable app-level state solution. If updates are frequent and different consumers need independent slices, split contexts or consider an external store with selectors.

In React 19, you can call Provider directly on the context (<ThemeContext> instead of <ThemeContext.Provider>) and read a context conditionally with the new use(Context) API:

import { createContext, use, useMemo, useState } from 'react';
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
// React 19: <Context> works as a Provider directly
return <ThemeContext value={value}>{children}</ThemeContext>;
}
function ThemedButton({ enabled }) {
if (!enabled) return null;
// `use` can be called conditionally; `useContext` cannot
const { theme, setTheme } = use(ThemeContext);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Theme: {theme}
</button>
);
}

When to use Context

Context fits values that many descendants need and that can tolerate its broadcast-style subscription behavior.

  • Passing rarely-changing values (theme, locale, current user, router) deep into the tree
  • Avoiding prop-drilling for a value the whole subtree needs
  • Combined with useReducer for app-scoped settings

When not to use Context

Avoid one broad context when updates are frequent or consumers need independent subscriptions.

  • High-frequency updates (form input on every keystroke, animation state)
  • Independent slices that should not re-render each other — split into multiple contexts or use a store with selectors

External client-state libraries

When many unrelated components need to read and write the same frequently-changing client state, an external store can notify only subscribers whose selected slice changed. External stores can also provide middleware, devtools, and persistence.

  • Zustand — a small hook-based store with selector subscriptions and no required provider.
  • Jotai — an atomic, bottom-up model where each piece of state is an atom; an atom update notifies consumers that read the changed atom.
  • Redux Toolkit (RTK) — the modern, batteries-included Redux. The legacy createStore from redux is deprecated; use configureStore + createSlice. RTK includes Redux DevTools (which support time-travel debugging) and pairs with RTK Query for server state.
  • MobX — observable/reactive model, popular in some enterprise codebases.
  • Recoil is no longer a current choice because its repository was archived in 2025.

When to use an external store

An external store becomes useful when subscription control or capabilities outside the component tree outweigh the added dependency.

  • Many unrelated components share the same updates and Context causes too many re-renders
  • You need devtools, middleware (logging, persistence, undo/redo), or strict action-based update flows
  • State needs to live outside the React tree (e.g. accessed from a non-React module)

Example with Redux Toolkit

Redux Toolkit centralizes named transitions and exposes selector-based reads through React Redux:

// counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0 },
reducers: {
increment: (state) => {
state.count += 1; // RTK uses Immer, so direct mutation is fine
},
},
});
export const { increment } = counterSlice.actions;
export default counterSlice.reducer;
// store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: { counter: counterReducer },
});
// Counter.js
import { useSelector, useDispatch } from 'react-redux';
import { increment } from './counterSlice';
function Counter() {
const count = useSelector((state) => state.counter.count);
const dispatch = useDispatch();
return <button onClick={() => dispatch(increment())}>Count: {count}</button>;
}
// App.js
import { Provider } from 'react-redux';
import { store } from './store';
function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}

Example with Zustand

Zustand exposes a store Hook whose selector determines the slice a component subscribes to:

import { create } from 'zustand';
const useCounter = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() {
const count = useCounter((s) => s.count);
const increment = useCounter((s) => s.increment);
return <button onClick={increment}>Count: {count}</button>;
}

Server state vs client state

A critical modern decision axis: data fetched from a server is not ordinary client state. It has a cache, can go stale, needs revalidation, refetching on focus, deduplication, retries, and pagination. Hand-rolling all of this on top of useState or Redux is error-prone.

For server data that needs a client cache, consider a dedicated server-state library:

  • TanStack Query (formerly React Query) — a widely used, framework-agnostic option.
  • SWR — lightweight alternative from Vercel.
  • RTK Query — built into Redux Toolkit, integrates with the same store.

Keeping cached server data separate from client-only state can simplify the client store and make invalidation rules explicit.

import { useQuery } from '@tanstack/react-query';
function Profile({ id }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', id],
queryFn: async () => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
},
});
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return <h1>{data.name}</h1>;
}

Quick decision guide

The following table summarizes common starting points rather than hard requirements:

Choosing an owner for application state
SituationUse
State used by one componentuseState
Several related fields, complex transitionsuseReducer
Same value needed deep in the tree, changes rarelyContext (+ useReducer if needed)
Frequently-changing client state shared widelyZustand / Jotai / Redux Toolkit
Data fetched from a serverTanStack Query / SWR / RTK Query
Form stateReact Hook Form / TanStack Form
URL-driven state (filters, tabs)Router search params (e.g. TanStack Router, Next.js)

Further reading

Exercises

Check your understanding
Beta
Check your understanding Exercise
Check your understanding Exercise

Which state-management choices fit their stated requirements? Select all that apply.