Skip to main content
Back to Blog
ReactState ManagementTypeScriptWeb Development

React State Management in 2025: Choosing the Right Approach

A practical guide to React state management options in 2025. Compare useState, useReducer, Context, Zustand, and server state with real-world use cases and performance considerations.

7 min read

State management in React has evolved significantly. With React Server Components, improved Context, and mature third-party libraries, choosing the right approach is more nuanced than ever. Here's what I've learned building production applications like Prop Monkey and InsureSignal.

If you're new to TypeScript with React, start with my TypeScript best practices guide first.

The State Management Spectrum

Local State ────────────────────────────────────► Global State
   │                                                    │
useState    useReducer    Context    Zustand/Jotai    Redux
   │                                                    │
Simple      Complex       Prop        Complex          Enterprise
Component   Component     Drilling    Client State     Apps
State       Logic         Solution    Management

1. useState: The Foundation

For component-local state, useState is still king:

function SearchInput() {
  const [query, setQuery] = useState('');
  const [isSearching, setIsSearching] = useState(false);

  const handleSearch = async () => {
    setIsSearching(true);
    await performSearch(query);
    setIsSearching(false);
  };

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        disabled={isSearching}
      />
      <button onClick={handleSearch} disabled={isSearching}>
        {isSearching ? 'Searching...' : 'Search'}
      </button>
    </div>
  );
}

When to use: Single component state, form inputs, UI toggles, local loading states.

2. useReducer: Complex Component Logic

When state updates become interdependent:

interface FormState {
  values: Record<string, string>;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
  isSubmitting: boolean;
}

type FormAction =
  | { type: 'SET_VALUE'; field: string; value: string }
  | { type: 'SET_ERROR'; field: string; error: string }
  | { type: 'TOUCH_FIELD'; field: string }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_END' }
  | { type: 'RESET' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_VALUE':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: '' },
      };
    case 'SET_ERROR':
      return {
        ...state,
        errors: { ...state.errors, [action.field]: action.error },
      };
    case 'TOUCH_FIELD':
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
      };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true };
    case 'SUBMIT_END':
      return { ...state, isSubmitting: false };
    case 'RESET':
      return initialFormState;
    default:
      return state;
  }
}

When to use: Complex forms, multi-step wizards, state machines.

3. Context: Avoiding Prop Drilling

Context is perfect for truly global values that rarely change:

// Theme context - changes infrequently
const ThemeContext = createContext<Theme>('dark');

function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('dark');

  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

// Usage
function Button() {
  const { theme } = useContext(ThemeContext);
  return <button className={theme === 'dark' ? 'btn-dark' : 'btn-light'} />;
}

Context Performance Gotcha

Context re-renders all consumers when the value changes. Split contexts for different update frequencies:

// Bad - everything re-renders when anything changes
const AppContext = createContext({ user, theme, notifications });

// Good - separate contexts for different update rates
const UserContext = createContext(user);         // Rarely changes
const ThemeContext = createContext(theme);       // Rarely changes
const NotificationContext = createContext([]);   // Changes often

When to use: Theme, auth state, localization, feature flags.

4. Zustand: Lightweight Global State

My go-to for client-side global state. It's simple, performant, and TypeScript-friendly:

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

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

const useCartStore = create<CartState>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],
        total: 0,

        addItem: (item) =>
          set((state) => {
            const existing = state.items.find((i) => i.id === item.id);
            if (existing) {
              return {
                items: state.items.map((i) =>
                  i.id === item.id
                    ? { ...i, quantity: i.quantity + 1 }
                    : i
                ),
                total: state.total + item.price,
              };
            }
            return {
              items: [...state.items, { ...item, quantity: 1 }],
              total: state.total + item.price,
            };
          }),

        removeItem: (id) =>
          set((state) => {
            const item = state.items.find((i) => i.id === id);
            return {
              items: state.items.filter((i) => i.id !== id),
              total: state.total - (item?.price ?? 0) * (item?.quantity ?? 0),
            };
          }),

        clearCart: () => set({ items: [], total: 0 }),
      }),
      { name: 'cart-storage' }
    )
  )
);

// Usage - only re-renders when items change
function CartCount() {
  const count = useCartStore((state) => state.items.length);
  return <span>{count}</span>;
}

When to use: Shopping carts, UI state (modals, sidebars), user preferences.

5. Server State: TanStack Query

For data from APIs, server state libraries are essential:

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

// Fetching data
function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then((r) => r.json()),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

// Mutating data
function useAddProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (product: Product) =>
      fetch('/api/products', {
        method: 'POST',
        body: JSON.stringify(product),
      }),
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });
}

// Usage
function ProductList() {
  const { data: products, isLoading, error } = useProducts();
  const addProduct = useAddProduct();

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <>
      {products.map((p) => <ProductCard key={p.id} product={p} />)}
      <button onClick={() => addProduct.mutate(newProduct)}>
        Add Product
      </button>
    </>
  );
}

When to use: API data, anything that lives on a server.

Decision Matrix

| Scenario | Solution | |----------|----------| | Form input value | useState | | Complex form with validation | useReducer + custom hook | | Theme/auth across app | Context | | Shopping cart | Zustand | | API data | TanStack Query | | Real-time data | TanStack Query + WebSocket | | URL state | nuqs or URL params |

2024 Best Practices

1. Colocate State

Keep state as close to where it's used as possible:

// Bad - lifting state too high
function App() {
  const [searchQuery, setSearchQuery] = useState('');
  return <DeepNestedSearch query={searchQuery} setQuery={setSearchQuery} />;
}

// Good - state where it's needed
function SearchSection() {
  const [query, setQuery] = useState('');
  return <SearchInput query={query} setQuery={setQuery} />;
}

2. Derive Don't Store

Calculate values instead of storing them:

// Bad - storing derived state
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
// Must remember to update total whenever items change

// Good - derive the value
const [items, setItems] = useState([]);
const total = useMemo(
  () => items.reduce((sum, item) => sum + item.price, 0),
  [items]
);

3. Server Components First

With Next.js 13+, fetch data on the server when possible:

// Server Component - no client state needed
async function ProductPage({ id }: { id: string }) {
  const product = await db.products.findUnique({ where: { id } });
  return <ProductDetails product={product} />;
}

My Stack

For my projects like InsureSignal and Prop Monkey, I use:

  • useState/useReducer: Component-level state
  • Zustand: Client-side global state (cart, UI)
  • TanStack Query: Server state
  • URL params: Filters, pagination, search

This combination covers 99% of use cases without the complexity of Redux.

Conclusion

The best state management is the simplest that solves your problem:

  1. Start with useState
  2. Upgrade to useReducer for complex logic
  3. Use Context sparingly for truly global values
  4. Add Zustand for shared client state
  5. Use TanStack Query for server data

Don't reach for a global state library until you need it. You'd be surprised how far useState and good component structure can take you.

For more React patterns, check out my blog posts and projects.


Related Articles: