ReactState Management Overview

State Management Overview

Every React app has state — but not all state is the same. The most common mistake developers make when an app grows is reaching for a single state management solution for all state, regardless of what that state represents. This leads to global stores full of loading flags, or complex cache invalidation logic in what should be simple UI toggles. The key insight: there are three distinct kinds of state, and each has a different optimal solution.

The Three Types of State

Type

Examples

Best Tool

UI / Local state

isOpen, inputValue, activeTab, isExpanded

useState / useReducer

Server / Async state

fetched users, loading flag, error, cache

TanStack Query / SWR

Global client state

auth user, theme, shopping cart, feature flags

Zustand / Redux

Type 1 — UI / Local State

UI state is ephemeral: it exists only to control the interface. Whether a dropdown is open, which tab is active, what text is in an input — this state is inherently local to a component or a small subtree. Using a global store for it is massive overkill:

JSX
import { useState } from 'react'

// ✓ Local state — perfect use case for useState
function Accordion({ title, children }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(o => !o)}>
        {title} {isOpen ? '▲' : '▼'}
      </button>
      {isOpen && <div>{children}</div>}
    </div>
  )
}

// ✓ Multiple related fields → useReducer keeps them consistent
function LoginForm() {
  const [form, dispatch] = useReducer(
    (state, action) => ({ ...state, ...action }),
    { email: '', password: '', isSubmitting: false }
  )

  return (
    <form>
      <input
        value={form.email}
        onChange={e => dispatch({ email: e.target.value })}
      />
      <input
        type="password"
        value={form.password}
        onChange={e => dispatch({ password: e.target.value })}
      />
    </form>
  )
}
Type 2 — Server / Async State

Server state is data that lives on a server and is synchronized into your UI. It is asynchronous (requires a network round-trip), shared (the server is the source of truth, not your client), and potentially stale (someone else may have changed it). Managing this manually with useState + useEffect is tedious and error-prone. Libraries like TanStack Query (React Query) or SWR exist specifically for this pattern:

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

// ✓ TanStack Query handles: loading, error, caching, deduplication,
//   background refetch, stale-while-revalidate, and pagination
function UserList() {
  const { data: users, isLoading, isError } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json()),
    staleTime: 60_000,   // consider data fresh for 1 minute
  })

  if (isLoading) return <p>Loading…</p>
  if (isError)   return <p>Error loading users</p>

  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  )
}

// Mutations invalidate the cache and trigger a refetch automatically
function AddUserButton() {
  const queryClient = useQueryClient()
  const mutation = useMutation({
    mutationFn: (user) => fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(user),
    }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })

  return (
    <button onClick={() => mutation.mutate({ name: 'New User' })}>
      Add User
    </button>
  )
}
Note
Don't put server data in a global store like Redux. Redux doesn't know about stale data, background refetching, or cache invalidation. You'd be reimplementing what TanStack Query already does — and doing it worse.
Type 3 — Global Client State

Global client state is data that is: (a) not from the server, (b) needed by many components across the tree, and (c) persists for the session. Classic examples: the authenticated user object, the active theme, the shopping cart, notification preferences:

JSX
import { create } from 'zustand'

// ✓ Zustand — minimal, no boilerplate, selector-based subscriptions
const useAppStore = create(set => ({
  user: null,
  theme: 'light',
  notifications: [],

  setUser:   user  => set({ user }),
  setTheme:  theme => set({ theme }),
  addNotification: (msg) => set(state => ({
    notifications: [...state.notifications, { id: Date.now(), msg }],
  })),
  dismissNotification: (id) => set(state => ({
    notifications: state.notifications.filter(n => n.id !== id),
  })),
}))

// Components only subscribe to what they need
function UserAvatar() {
  const user = useAppStore(state => state.user)
  return user ? <img src={user.avatarUrl} alt={user.name} /> : null
}

function ThemeToggle() {
  const theme    = useAppStore(state => state.theme)
  const setTheme = useAppStore(state => state.setTheme)
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      {theme === 'light' ? '🌙' : '☀️'}
    </button>
  )
}
The Common Mistake: One Tool for Everything
Warning
Putting everything in Redux (or any single store) is the most common state management mistake. You end up with isUsersLoading, usersError, and usersLastFetched fields in your store — reimplementing a cache. Keep each type of state in its natural home.

JSX
// ✗ The kitchen-sink Redux store — antipattern
const store = {
  // Global client state (fine for Redux)
  user: null,
  theme: 'light',

  // Server state (should be in TanStack Query)
  users: [],
  usersLoading: false,
  usersError: null,
  usersLastFetched: null,

  // UI state (should be useState in the component)
  isModalOpen: false,
  activeTab: 'overview',
  searchQuery: '',
}

// ✓ Each in its natural home
// useState    → isModalOpen, activeTab, searchQuery
// TanStack Q  → users (with built-in loading/error/cache)
// Zustand     → user, theme (true global client state)
The Prop Drilling Spectrum

When state needs to be shared between components, the right solution depends on how many levels deep it needs to travel:

  • 1-2 levels — pass props directly. Simple, explicit, no abstraction needed.

  • 3-4 levels — lift state to the nearest common ancestor and pass down. Still manageable.

  • 5+ levels or many consumers — reach for Context (for infrequently-changing values like theme/locale) or Zustand (for frequently-changing values).

  • Complex interactions, time-travel debugging, strict action contracts — Redux.

Decision Flowchart

Text
Is this state needed by only 1 component?
  └─ YES → useState / useReducer inside the component

Is this data fetched from a server (async)?
  └─ YES → TanStack Query or SWR

Is this state needed by many components across the tree,
and it changes infrequently (theme, locale, auth)?
  └─ YES → React Context

Is this state needed by many components and changes frequently?
  └─ YES → Zustand or Jotai

Do you need time-travel debugging, strict action contracts,
complex middleware, or enterprise team conventions?
  └─ YES → Redux Toolkit
Tip
For most apps under ~50 components, useState + TanStack Query covers 90% of needs. Introduce Zustand when you first feel prop drilling pain. Reach for Redux only if your team already knows it or you specifically need its DevTools / middleware ecosystem.
How Complexity Grows with App Size

App size

Typical state setup

Single page / prototype

useState everywhere, fetch in useEffect

Small app (< 10 pages)

useState + TanStack Query

Medium app (10–50 pages)

useState + TanStack Query + Zustand for 1–2 global slices

Large / enterprise app

useState + TanStack Query + Redux Toolkit (complex interactions)

  • UI state — local, ephemeral, controls the interface → useState / useReducer.

  • Server state — async, shared with server, can be stale → TanStack Query / SWR.

  • Global client state — session-scoped, needed across the tree → Zustand / Redux.

  • Mix tools: most apps use all three categories simultaneously.

  • Start simple. Add complexity only when you feel the pain of the simpler solution.