React State Management: The Complete Guide for 2026

Published February 12, 2026 · 20 min read

State management is the hardest problem in React. Not because React lacks tools — it has too many. Between useState, useReducer, Context, Redux Toolkit, Zustand, Jotai, and TanStack Query, picking the right approach for each situation is the real challenge. This guide cuts through the noise with practical comparisons, real code examples, and a clear decision framework so you can choose confidently.

⚙ Try it: Use our JSON Formatter to inspect API state payloads, and check the JavaScript Cheat Sheet for destructuring and spread syntax used throughout.

Understanding State in React

State is any data that changes over time and affects rendering. React distinguishes several categories:

  • Local/UI state — form inputs, toggles, dropdown open/close. Owned by one component.
  • Shared state — data used by sibling or cousin components. Lifted to a common ancestor.
  • Global state — auth user, theme, shopping cart. Needed application-wide.
  • Server state — API data cached on the client. Has its own lifecycle: loading, fresh, stale, error.
  • URL state — query params, path segments. Often overlooked but extremely useful for shareable views.

The key insight: not all state belongs in the same place. Mixing server cache with UI toggles in a single Redux store is a common mistake that creates unnecessary complexity.

Local State: useState and useReducer

useState for Simple Values

useState handles strings, numbers, booleans, and simple objects. It covers 80% of state needs.

function SearchBar() {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? 'Close' : 'Search'}
      </button>
      {isOpen && (
        <input
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search tools..."
        />
      )}
    </div>
  );
}

useReducer for Complex State Logic

When state transitions depend on previous state or involve multiple related values, useReducer shines:

const initialState = { items: [], loading: false, error: null };

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.payload)
      };
    case 'SET_LOADING':
      return { ...state, loading: action.payload, error: null };
    case 'SET_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const addItem = (item) => dispatch({ type: 'ADD_ITEM', payload: item });
  const removeItem = (id) => dispatch({ type: 'REMOVE_ITEM', payload: id });

  return (
    <ul>
      {state.items.map(item => (
        <li key={item.id}>
          {item.name}
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

Rule of thumb: use useState for independent values, useReducer when the next state depends on the previous state or when you have 3+ related state variables.

Lifting State Up

When two sibling components need shared data, move the state to their closest common parent and pass it down as props:

function ProductPage() {
  const [selectedColor, setSelectedColor] = useState('blue');

  return (
    <div>
      <ColorPicker
        color={selectedColor}
        onChange={setSelectedColor}
      />
      <ProductPreview color={selectedColor} />
    </div>
  );
}

This works perfectly for 1-2 levels. Beyond that, you hit prop drilling — passing props through components that don't use them. That's where Context or external libraries come in.

React Context API

Context lets you broadcast data to any descendant without explicit prop passing. Three steps: create, provide, consume.

// 1. Create the context
const ThemeContext = createContext('light');

// 2. Provide it at the top
function App() {
  const [theme, setTheme] = useState('dark');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Layout />
    </ThemeContext.Provider>
  );
}

// 3. Consume it anywhere below
function Sidebar() {
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
      Current: {theme}
    </button>
  );
}

Best practice: wrap your context in a custom provider with a custom hook to avoid importing both the context and useContext everywhere:

// auth-context.js
const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const login = async (creds) => { /* ... */ };
  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be inside AuthProvider');
  return ctx;
}

Why Context Alone Isn't Enough

Context has a fundamental performance problem: every consumer re-renders when the provider value changes, even if that consumer only uses a subset of the value. There's no built-in selector mechanism.

// Problem: changing 'theme' also re-renders components
// that only read 'locale'
<AppContext.Provider value={{ theme, locale, user }}>
  <ThemeDisplay />   {/* re-renders on locale change too */}
  <LocaleDisplay />  {/* re-renders on theme change too */}
</AppContext.Provider>

Workarounds exist (splitting contexts, memoizing values), but they add complexity. For frequently updated state shared across many components, a dedicated library provides built-in selectors and fine-grained subscriptions.

Context is still excellent for infrequently changing data: themes, auth, locale, feature flags. Don't use it as a general-purpose state manager for rapidly updating data like form fields or animations.

Redux Toolkit

Redux Toolkit (RTK) is the official, batteries-included way to use Redux. It eliminates boilerplate with createSlice, configureStore, and includes Immer for immutable updates.

// store/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: (state, action) => {
      state.push({ id: Date.now(), text: action.payload, done: false });
    },
    toggleTodo: (state, action) => {
      const todo = state.find(t => t.id === action.payload);
      if (todo) todo.done = !todo.done;
    },
    removeTodo: (state, action) => {
      return state.filter(t => t.id !== action.payload);
    }
  }
});

export const { addTodo, toggleTodo, removeTodo } = todosSlice.actions;
export default todosSlice.reducer;
// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './todosSlice';

export const store = configureStore({
  reducer: { todos: todosReducer }
});
// Component usage
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, toggleTodo } from './store/todosSlice';

function TodoList() {
  const todos = useSelector(state => state.todos);
  const dispatch = useDispatch();

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
          {todo.done ? '✓' : '☐'} {todo.text}
        </li>
      ))}
    </ul>
  );
}

RTK Query for Data Fetching

RTK Query handles server state within Redux, including caching, polling, and cache invalidation:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({ query: () => '/posts', providesTags: ['Post'] }),
    addPost: builder.mutation({
      query: (body) => ({ url: '/posts', method: 'POST', body }),
      invalidatesTags: ['Post']
    })
  })
});

export const { useGetPostsQuery, useAddPostMutation } = api;

Zustand

Zustand is a tiny (~1KB), hook-based state library with zero boilerplate. No providers, no context, no reducers. Just a function that returns your store.

import { create } from 'zustand';

const useStore = create((set, get) => ({
  count: 0,
  todos: [],
  increment: () => set((s) => ({ count: s.count + 1 })),
  addTodo: (text) => set((s) => ({
    todos: [...s.todos, { id: Date.now(), text, done: false }]
  })),
  toggleTodo: (id) => set((s) => ({
    todos: s.todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
  })),
  get todoCount() { return get().todos.length; }
}));

// Usage in any component - no Provider needed
function Counter() {
  const count = useStore((s) => s.count);
  const increment = useStore((s) => s.increment);
  return <button onClick={increment}>Count: {count}</button>;
}

function TodoCount() {
  const total = useStore((s) => s.todos.length);
  return <span>{total} todos</span>;
}

The selector (s) => s.count prevents re-renders when unrelated state changes. This solves Context's re-render problem with zero effort. Zustand also supports middleware for persistence, devtools, and Immer:

import { devtools, persist } from 'zustand/middleware';

const useStore = create(
  devtools(
    persist(
      (set) => ({ count: 0, increment: () => set(s => ({ count: s.count + 1 })) }),
      { name: 'app-store' }
    )
  )
);

Jotai

Jotai takes an atomic approach to state. Instead of a single store, you define independent atoms that compose together. This gives you bottom-up state management where each piece of state is granular.

import { atom, useAtom } from 'jotai';

// Primitive atoms
const countAtom = atom(0);
const nameAtom = atom('');

// Derived atom (read-only, recomputes automatically)
const greetingAtom = atom((get) => {
  const name = get(nameAtom);
  return name ? `Hello, ${name}!` : 'Hello, stranger!';
});

// Writable derived atom
const doubleCountAtom = atom(
  (get) => get(countAtom) * 2,
  (get, set, newValue) => set(countAtom, newValue / 2)
);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

function Greeting() {
  const [greeting] = useAtom(greetingAtom);
  return <p>{greeting}</p>;
}

Jotai shines when you have many independent pieces of state that occasionally depend on each other. Each atom triggers re-renders only in the components that subscribe to it. It is particularly strong for complex forms and dashboards.

TanStack Query (React Query)

TanStack Query handles server state — data from APIs that needs caching, refetching, pagination, and synchronization. It is not a replacement for client state libraries; it complements them.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function PostList() {
  const { data: posts, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(r => r.json()),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });

  const queryClient = useQueryClient();
  const createPost = useMutation({
    mutationFn: (newPost) => fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(newPost),
      headers: { 'Content-Type': 'application/json' }
    }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] })
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <button onClick={() => createPost.mutate({ title: 'New Post' })}>
        Add Post
      </button>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  );
}

Key features: automatic background refetching, cache invalidation, optimistic updates, infinite scroll support, and SSR/hydration support. Once you adopt TanStack Query for server data, you'll find that most of your Redux/Zustand state was actually server state in disguise.

Choosing the Right Tool

Follow this decision process:

  1. Is it server data (API responses)? Use TanStack Query or RTK Query. Stop here.
  2. Is it local to one component? Use useState or useReducer. Stop here.
  3. Is it shared between a parent and 1-2 children? Lift state up with props. Stop here.
  4. Is it infrequently updated (theme, auth, locale)? Use Context. Stop here.
  5. Is it frequently updated global state? Use Zustand (simple), Jotai (atomic/granular), or Redux Toolkit (large teams).

Most applications need a combination. A typical setup: TanStack Query for server data, useState/useReducer for local UI, Context for auth/theme, and Zustand for the remaining global client state.

Comparison Table

Feature Context Redux Toolkit Zustand Jotai TanStack Query
Bundle size0 KB~11 KB~1 KB~2 KB~13 KB
BoilerplateLowMediumVery LowVery LowLow
SelectorsNoYesYesYes (atoms)Yes (select)
DevToolsReact DevToolsRedux DevToolsRedux DevToolsCustomRQ DevTools
MiddlewareNoYesYesLimitedN/A
Server stateNoRTK QueryNojotai-tanstack-queryYes (primary)
Provider neededYesYesNoOptionalYes
Best forTheme, authLarge appsMost appsGranular stateAPI data

Preventing Unnecessary Re-renders

Re-renders are the #1 performance problem in React apps. Every state management approach has its own strategy.

React.memo for Component-Level Control

const ExpensiveList = React.memo(function ExpensiveList({ items }) {
  return items.map(item => <div key={item.id}>{item.name}</div>);
});
// Only re-renders when `items` reference changes

Zustand Selectors

// Only re-renders when 'count' changes, ignores other state
const count = useStore((s) => s.count);

// For derived data, use shallow comparison
import { shallow } from 'zustand/shallow';
const { name, email } = useStore(
  (s) => ({ name: s.name, email: s.email }),
  shallow
);

Redux useSelector

// Already uses strict equality by default
const count = useSelector((state) => state.counter.value);

// For derived data, use createSelector (memoized)
import { createSelector } from '@reduxjs/toolkit';
const selectCompletedTodos = createSelector(
  (state) => state.todos,
  (todos) => todos.filter(t => t.done)
);

Jotai: Granular by Design

Each atom is independent. A component using countAtom never re-renders from a nameAtom update. This is the most granular approach with zero configuration.

Common Patterns

Compound State with useReducer

Group related state that transitions together:

function fetchReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { data: action.payload, loading: false, error: null };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

function useFetch(url) {
  const [state, dispatch] = useReducer(fetchReducer, {
    data: null, loading: true, error: null
  });

  useEffect(() => {
    let cancelled = false;
    dispatch({ type: 'FETCH_START' });
    fetch(url)
      .then(r => r.json())
      .then(data => { if (!cancelled) dispatch({ type: 'FETCH_SUCCESS', payload: data }); })
      .catch(err => { if (!cancelled) dispatch({ type: 'FETCH_ERROR', payload: err.message }); });
    return () => { cancelled = true; };
  }, [url]);

  return state;
}

State Machines with XState

For complex workflows (multi-step forms, payment flows, wizards), state machines make impossible states impossible:

import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';

const checkoutMachine = createMachine({
  id: 'checkout',
  initial: 'cart',
  context: { items: [], address: null, payment: null },
  states: {
    cart:     { on: { NEXT: 'shipping' } },
    shipping: { on: { NEXT: 'payment', BACK: 'cart',
      SET_ADDRESS: { actions: assign({ address: (_, e) => e.data }) }
    }},
    payment:  { on: { NEXT: 'confirm', BACK: 'shipping' } },
    confirm:  { on: { SUBMIT: 'processing', BACK: 'payment' } },
    processing: { invoke: {
      src: 'submitOrder',
      onDone: 'success',
      onError: 'error'
    }},
    success:  { type: 'final' },
    error:    { on: { RETRY: 'processing' } }
  }
});

function Checkout() {
  const [state, send] = useMachine(checkoutMachine);
  // state.value tells you exactly which step you're on
  // No impossible transitions possible
}

Best Practices

  1. Start local, promote when needed. Use useState first. Only move to global state when prop drilling becomes painful.
  2. Separate server state from client state. Use TanStack Query or RTK Query for API data. Don't put fetch results in Zustand/Redux unless you have a specific reason.
  3. Keep stores small and focused. Multiple small stores beat one monolithic store. Zustand and Jotai make this natural.
  4. Use selectors everywhere. Never subscribe to the entire store. Select only the fields your component needs.
  5. Normalize nested data. If your store has deeply nested objects, flatten them. Use entity IDs and lookup maps instead of nested arrays.
  6. Colocate state with its UI. If only one component uses a piece of state, keep it in that component. Don't hoist everything to global just because you can.
  7. Type your state with TypeScript. Define interfaces for your store state and action types. It catches bugs at compile time and provides autocompletion.
  8. Use devtools during development. Redux DevTools works with Redux Toolkit and Zustand. TanStack Query has its own devtools panel. Use them to debug state changes.
  9. Don't optimize prematurely. React is fast. Only add React.memo, useMemo, or selector memoization when profiling shows actual problems.
  10. Test state logic independently. Reducers are pure functions — test them without rendering. Zustand stores can be tested outside components. Separate logic from UI.

Frequently Asked Questions

What is state management in React?

State management in React is the practice of handling data that changes over time and affects what your UI renders. It includes local component state (useState, useReducer), shared state between components (Context, lifting state up), and global application state managed by libraries like Redux Toolkit, Zustand, or Jotai. Choosing the right approach depends on how many components need the data and how often it changes.

Should I use React Context or Redux for state management?

Use React Context for low-frequency updates like themes, locale, and auth status. Use Redux Toolkit (or Zustand) when you have frequently changing global state that many components read, because Context re-renders every consumer on every update. Redux provides devtools, middleware, and fine-grained subscriptions that Context lacks. For most mid-sized apps, Zustand offers the same benefits with less boilerplate.

What is the difference between Zustand and Redux Toolkit?

Zustand is a minimal, hook-based state library with almost no boilerplate. You create a store with a single function call and consume it with a hook. Redux Toolkit is more structured with slices, reducers, and a formal action dispatch pattern. Zustand has a smaller bundle size (~1KB vs ~11KB for RTK) and simpler API, while Redux Toolkit offers better devtools, middleware ecosystem, and RTK Query for data fetching. Choose Zustand for simplicity, Redux Toolkit for large teams needing strict patterns.

When should I use global state vs local state in React?

Keep state local by default. Only lift it to global state when multiple unrelated components need the same data, when state must persist across route changes, or when the prop drilling depth exceeds 3-4 levels. Common global state: auth user, theme, shopping cart, notifications. Common local state: form inputs, toggle visibility, animation state. A good rule is to start local and promote to global only when you feel real pain from prop drilling.

What is the difference between server state and client state?

Client state is data owned and controlled entirely by the frontend: UI toggles, form values, selected tabs. Server state is data that lives on the server and is cached locally: user profiles, product lists, API responses. Server state needs caching, background refetching, and stale-while-revalidate strategies. Libraries like TanStack Query (React Query) handle server state, while useState, Zustand, or Redux handle client state. Mixing them into one store creates unnecessary complexity.

Related Resources

Keep building: State management is a spectrum, not a binary choice. Start with React Hooks for local state, add TypeScript for type safety, and reach for Zustand or TanStack Query only when you need them. Check our JSON Formatter for debugging API payloads during development.

Related Resources

React Hooks Complete Guide
Deep dive into useState, useEffect, and React 19 hooks
Next.js Complete Guide
Full-stack React with server components and actions
TypeScript Complete Guide
Type your stores, actions, and selectors safely
Promises & Async/Await Guide
Async foundations behind data fetching patterns