State Management Patterns

Managing state is arguably the hardest part of building large React applications. “State” is the data that changes over time—user inputs, server responses, UI toggles.

In the early days, we only had Redux. Today, we have a rich ecosystem of tools tailored for specific problems. This chapter guides you through choosing the right one.

flowchart TD
    A["What kind of state?"] --> B{"UI-only?<br/>(toggles, forms)"}
    B -->|Yes| C["useState / useReducer"]
    B -->|No| D{"Shared across<br/>many components?"}
    D -->|No| C
    D -->|Yes| E{"High-frequency<br/>updates?"}
    E -->|No| F["React Context"]
    E -->|Yes| G["Zustand / Jotai"]
    A --> H{"Server data?<br/>(API responses)"}
    H -->|Yes| I["TanStack Query"]
    A --> J{"In the URL?<br/>(filters, tabs)"}
    J -->|Yes| K["URL State (nuqs)"]
    
    style C fill:#10b981
    style F fill:#3b82f6
    style G fill:#8b5cf6
    style I fill:#f59e0b
    style K fill:#ec4899


1. Local State: useState & useReducer

For UI state that belongs to a single component (e.g., form inputs, toggles), sticking to React’s built-in hooks is best.

  • useState: Simple values.
  • useReducer: Complex state logic or next-state dependency.

2. Global State: The Context API

When you need to share state across many components (e.g., Theme, User Auth, Language), usage of React Context prevents “Prop Drilling”.

Best Practice: Separate State and Dispatch

To avoid unnecessary re-renders, split your context into two: one for the value, one for the update function.

const UserContext = createContext<User | null>(null);
const UserDispatchContext = createContext<Dispatch<UserAction> | null>(null);

Warning: Performance Pitfall

Context is not optimized for high-frequency updates (like mouse coordinates or stock tickers). Every consumer re-renders when the value changes. For those cases, use an external store.


3. Zustand: The Modern Default

For global state that changes often or needs to be accessed anywhere (outside components), Zustand is the modern favorite. It’s tiny (~1KB), fast, and hook-based.

npm install zustand

Creating the Store

import { create } from 'zustand'

interface CartState {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
  clearCart: () => void
}

const useCartStore = create<CartState>((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ 
    items: [...state.items, item] 
  })),
  removeItem: (id) => set((state) => ({ 
    items: state.items.filter(item => item.id !== id) 
  })),
  clearCart: () => set({ items: [] }),
}))

Using the Store

function CartCounter() {
  // Select only what you need! Components re-render ONLY if 'items.length' changes.
  const itemCount = useCartStore((state) => state.items.length)
  return <span className="badge">{itemCount}</span>
}

function AddToCartButton({ product }) {
  const addItem = useCartStore((state) => state.addItem)
  return <button onClick={() => addItem(product)}>Add to Cart</button>
}

Zustand + Immer for Immutable Updates

For complex nested state, use immer middleware to write “mutative” code that produces immutable updates.

npm install immer
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

interface SettingsState {
  user: {
    profile: {
      name: string
      preferences: {
        theme: 'light' | 'dark'
        notifications: boolean
      }
    }
  }
  updateTheme: (theme: 'light' | 'dark') => void
}

const useSettingsStore = create<SettingsState>()(
  immer((set) => ({
    user: {
      profile: {
        name: 'John',
        preferences: {
          theme: 'light',
          notifications: true,
        },
      },
    },
    // ✅ Mutate directly - immer handles immutability
    updateTheme: (theme) => set((state) => {
      state.user.profile.preferences.theme = theme
    }),
  }))
)

Zustand Persist: Sync with localStorage

Persist state across page reloads automatically.

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      user: null,
      login: (token, user) => set({ token, user }),
      logout: () => set({ token: null, user: null }),
    }),
    {
      name: 'auth-storage', // localStorage key
      partialize: (state) => ({ token: state.token }), // Only persist token
    }
  )
)
TipCombine Middlewares

You can combine persist + immer + devtools:

create()(devtools(persist(immer((set) => ({ ... })))))

4. Jotai: Atomic State

Jotai takes a different approach: state is broken into atoms (tiny pieces) that can be composed together. It’s particularly well-suited for React Server Components.

npm install jotai

Creating Atoms

import { atom } from 'jotai'

// Primitive atom
const countAtom = atom(0)

// Derived atom (computed value)
const doubledAtom = atom((get) => get(countAtom) * 2)

// Async atom
const userAtom = atom(async () => {
  const response = await fetch('/api/user')
  return response.json()
})

Using Atoms

import { useAtom, useAtomValue, useSetAtom } from 'jotai'

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

function Display() {
  const doubled = useAtomValue(doubledAtom) // Read-only
  return <span>Doubled: {doubled}</span>
}

function ResetButton() {
  const setCount = useSetAtom(countAtom) // Write-only
  return <button onClick={() => setCount(0)}>Reset</button>
}

When to Choose Jotai vs Zustand

Zustand Jotai
Single store with slices Many small atoms
Easier to understand More flexible composition
Better for complex logic Better for derived state
Works outside React React-first design

5. Server State: TanStack Query

Stop putting server data in Redux!

Server state (API responses) is different from Client state. It needs caching, deduplication, background updates, and stale-while-revalidate logic.

TanStack Query handles all of this automatically. See the Data Fetching Strategies chapter for comprehensive coverage.

function Todos() {
  const { isPending, error, data } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodoList,
  })

  if (isPending) return 'Loading...'
  if (error) return 'An error has occurred: ' + error.message

  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

6. URL State: The Underrated Pattern

State that belongs in the URL (filters, sorting, pagination, active tabs) is often mismanaged. URL state enables:

  • Shareable links — Users can share their exact view
  • Back/forward navigation — Browser history just works
  • Bookmarking — Save specific states
  • SEO — Search engines can index filtered views

nuqs: Type-Safe URL State

npm install nuqs
import { useQueryState } from 'nuqs'

function ProductFilters() {
  const [category, setCategory] = useQueryState('category')
  const [sort, setSort] = useQueryState('sort', { defaultValue: 'price' })
  const [page, setPage] = useQueryState('page', { 
    parse: parseInt,
    defaultValue: 1 
  })

  // URL: /products?category=electronics&sort=price&page=2
  
  return (
    <div>
      <select 
        value={category ?? ''} 
        onChange={(e) => setCategory(e.target.value || null)}
      >
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>
      
      <button onClick={() => setPage(page + 1)}>Next Page</button>
    </div>
  )
}

When to Use URL State

✅ Use URL State ❌ Don’t Use URL State
Filters, sorting Form input values
Pagination Modal open/close
Active tab Hover states
Search query Animation states
Selected items (IDs) Temporary UI state

7. The Future: What’s Coming

React Server Components Change Everything

With RSC, many state patterns become unnecessary:

// OLD: Fetch in useEffect, store in state
const [products, setProducts] = useState([])
useEffect(() => {
  fetch('/api/products').then(r => r.json()).then(setProducts)
}, [])

// NEW: Just fetch in a Server Component
async function ProductList() {
  const products = await db.products.findMany()
  return <ul>{products.map(p => <li>{p.name}</li>)}</ul>
}

Signals (Potential Future)

Signals are a reactive primitive that React may adopt. They allow fine-grained reactivity without re-rendering entire components.

// Hypothetical future React with signals
import { signal, computed } from 'react'

const count = signal(0)
const doubled = computed(() => count.value * 2)

function Counter() {
  // Only the <span> re-renders when count changes
  return (
    <div>
      <span>{count.value}</span>
      <button onClick={() => count.value++}>+</button>
    </div>
  )
}
NoteSignals Status

As of 2026, React hasn’t officially adopted signals, but frameworks like Solid.js, Preact, and Angular use them. Watch the React RFC discussions for updates.


Summary: The Modern State Stack

State Type 2024-2026 Recommendation
Local UI useState / useReducer
Shared UI Zustand (or Jotai)
Server Data TanStack Query
URL State nuqs / TanStack Router
Forms React Hook Form + Zod
Complex/Legacy Redux Toolkit

pie title State Management Adoption (2026)
    "Zustand" : 35
    "TanStack Query" : 30
    "useState/Context" : 20
    "Redux Toolkit" : 10
    "Jotai/Other" : 5

By separating Server State (TanStack Query) from Client State (Zustand/Context) and putting URL State in the URL, you drastically simplify your application logic.