React

State Management Without Redux

A practical comparison of useState, Context, Zustand, and React Query for different types of state. How to choose, and why the answer is rarely Redux.

Redux isn't bad. It's just that most apps never needed it in the first place, and the React ecosystem has caught up enough that the few that did probably don't anymore. Before reaching for a global store, it's worth knowing what simpler tools already cover.

The honest answer to "how should I manage state?" is: it depends on what kind of state you're dealing with. UI state, shared app state, and server state are three different problems. Treating them all the same is where the complexity creeps in.

UI State: useState

For anything local to a component, useState is still the right tool. A toggle, a form field, a loading flag, a selected tab. If the state doesn't need to leave the component, don't let it.

function FilterPanel() {
    const [open, setOpen] = useState(false)
    const [query, setQuery] = useState('')

    return (
        <div>
            <button onClick={() => setOpen((prev) => !prev)}>Filters</button>
            {open && (
                <input
                    value={query}
                    onChange={(e) => setQuery(e.target.value)}
                    placeholder="Search..."
                />
            )}
        </div>
    )
}

Keep UI state close to where it's used. If two sibling components need it, lift it to their shared parent. That's the natural ceiling for useState.

Shared State: Context

When multiple components across the tree need the same value, React Context avoids threading props through every layer. It works well for things that don't change often: the current user, a theme, a locale setting.

// context/theme.tsx
import { createContext, useContext, useState } from 'react'

type Theme = 'light' | 'dark'

const ThemeContext = createContext<{
    theme: Theme
    toggle: () => void
} | null>(null)

export function ThemeProvider({ children }: { children: React.ReactNode }) {
    const [theme, setTheme] = useState<Theme>('light')
    const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'))

    return (
        <ThemeContext.Provider value={{ theme, toggle }}>
            {children}
        </ThemeContext.Provider>
    )
}

export function useTheme() {
    const ctx = useContext(ThemeContext)
    if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
    return ctx
}

The problem with Context is re-renders. Every consumer re-renders when the context value changes. If you're storing high-frequency state (a cursor position, a live counter) or a large object with many unrelated fields, that becomes a real cost. Context is great for low-frequency shared values. It's not a global state manager.

Global Client State: Zustand

For state that needs to be accessible anywhere, changes frequently, or drives a lot of UI updates, a lightweight store like Zustand is a better fit than Context.

// store/cart.ts
import { create } from 'zustand'

type CartItem = { id: string; name: string; quantity: number }

type CartStore = {
    items: CartItem[]
    add: (item: CartItem) => void
    remove: (id: string) => void
}

export const useCartStore = create<CartStore>((set) => ({
    items: [],
    add: (item) =>
        set((state) => ({
            items: [...state.items, item],
        })),
    remove: (id) =>
        set((state) => ({
            items: state.items.filter((i) => i.id !== id),
        })),
}))

Components subscribe to only the slice of state they need, so unrelated updates don't trigger re-renders. No providers required. No boilerplate. The store is just a hook.

function CartButton() {
    const count = useCartStore((state) => state.items.length)
    return <button>Cart ({count})</button>
}

Zustand covers what Redux used to justify its existence without the ceremony. You don't need action creators, reducers, or a middleware pipeline to track a cart or a modal stack.

Server State: React Query (TanStack Query)

Data fetched from an API is a different category entirely. It isn't really "your" state. It's a cache of server state that might be stale. Treating it like local state (fetching in useEffect, storing in useState) leads to problems: loading flickers, stale data, duplicate requests, manual cache invalidation.

React Query handles this correctly by default.

// hooks/use-products.ts
import { useQuery } from '@tanstack/react-query'

type Product = { id: string; name: string; price: number }

async function fetchProducts(): Promise<Product[]> {
    const res = await fetch('/api/products')
    if (!res.ok) throw new Error('Failed to fetch products')
    return res.json()
}

export function useProducts() {
    return useQuery({
        queryKey: ['products'],
        queryFn: fetchProducts,
        staleTime: 1000 * 60 * 5, // 5 minutes
    })
}
function ProductList() {
    const { data, isLoading, error } = useProducts()

    if (isLoading) return <p>Loading...</p>
    if (error) return <p>Something went wrong.</p>

    return (
        <ul>
            {data?.map((p) => (
                <li key={p.id}>{p.name}</li>
            ))}
        </ul>
    )
}

You get caching, deduplication, background refetching, and error handling in exchange for almost no configuration. Mutations, pagination, and optimistic updates are built in too. If your Redux store is mostly server data, React Query removes the need for it entirely.

How to Choose

The decision tree is straightforward:

  • Is the state only needed in one component? Use useState.
  • Do a few components across the tree need the same low-frequency value? Use Context.
  • Is it client state that changes often or is needed anywhere in the app? Use Zustand (or Jotai, which takes a similar atom-based approach).
  • Is it data that came from a server? Use React Query or SWR.

Redux makes sense when you need strict unidirectional data flow with a full audit trail, complex derived state across many slices, or powerful devtools with time-travel debugging in a large team setting. Those are real requirements. Most apps just don't have them.

The pattern that works for most projects: useState for local UI, Context for app-wide config (theme, auth), Zustand for shared interactive state, React Query for everything from the network. That covers the full surface area without pulling in a framework that adds as much complexity as it removes.

← Older
Structuring Large React Apps
Newer →
React Server Components vs Client Components

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.