Skip to main content
React State Management: When to Use What

React State Management: When to Use What

Oct 12, 2025

React state management is a mess of opinions. Every week theres a new library claiming to solve everything. Redux is dead. Long live Redux. Use Context. Dont use Context for state.

Heres my take after years of React apps: theres no best solution. Only the right tool for your situation.

The Decision Framework

1. useState: Start Here

For local component state, useState is perfect:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
    </div>
  );
}

Use when:

  • State belongs to one component
  • Simple values (strings, numbers, booleans)
  • No complex state transitions

2. useReducer: Complex Local State

When state logic gets complex, useReducer is cleaner:

interface State {
  items: Item[];
  loading: boolean;
  error: string | null;
}

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: Item[] }
  | { type: 'FETCH_ERROR'; payload: string }
  | { type: 'ADD_ITEM'; payload: Item }
  | { type: 'REMOVE_ITEM'; payload: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, items: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.payload)
      };
    default:
      return state;
  }
}

function ItemList() {
  const [state, dispatch] = useReducer(reducer, {
    items: [],
    loading: false,
    error: null
  });

  // dispatch({ type: 'ADD_ITEM', payload: newItem })
}

Use when:

  • Multiple related state values
  • Complex state transitions
  • State logic you want to test in isolation

3. Lifting State Up

Before reaching for global state, try lifting:

// Parent owns the state
function Parent() {
  const [items, setItems] = useState<Item[]>([]);

  return (
    <>
      <ItemList items={items} />
      <ItemForm onAdd={(item) => setItems(prev => [...prev, item])} />
    </>
  );
}

// Children receive via props
function ItemList({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

Use when:

  • State is shared between siblings
  • Prop drilling is 2-3 levels deep max

4. Context API: Infrequent Updates

Context is great for data that rarely changes:

interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | null>(null);

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

  const toggleTheme = useCallback(() => {
    setTheme(t => t === 'light' ? 'dark' : 'light');
  }, []);

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

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

The Context performance problem:

When ANY context value changes, ALL consumers re-render. Split contexts to avoid this:

// ❌ Bad: One big context
const AppContext = createContext({ user, settings, cart, notifications });

// ✅ Good: Separate contexts
const UserContext = createContext(user);
const SettingsContext = createContext(settings);
const CartContext = createContext(cart);

Use Context for:

  • Theme
  • Locale/i18n
  • Current user (auth state)
  • Feature flags

Dont use Context for:

  • Frequently updating data
  • Large state objects
  • State that changes on user interaction

5. Zustand: Simple External State

Zustand is my go-to for most apps. Simple API, good performance, no boilerplate:

import { create } from 'zustand';

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

const useCartStore = create<CartStore>((set, get) => ({
  items: [],

  addItem: (item) => set((state) => ({
    items: [...state.items, item]
  })),

  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id)
  })),

  clearCart: () => set({ items: [] }),

  total: () => {
    return get().items.reduce((sum, item) => sum + item.price, 0);
  }
}));

// Use it anywhere
function CartBadge() {
  const itemCount = useCartStore((state) => state.items.length);
  return <span>{itemCount}</span>;
}

function CartTotal() {
  const total = useCartStore((state) => state.total());
  return <span>${total}</span>;
}

Zustand selectors = performance:

// ❌ Bad: Re-renders on any store change
const { items, addItem } = useCartStore();

// ✅ Good: Only re-renders when items.length changes
const itemCount = useCartStore((state) => state.items.length);

Use Zustand when:

  • Need global state
  • Want minimal boilerplate
  • Small to medium apps
  • Dont need Redux DevTools or middleware ecosystem

6. Redux Toolkit: Large Apps

For large apps with complex state, Redux Toolkit is still solid:

import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit';

interface CartState {
  items: CartItem[];
  loading: boolean;
}

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [], loading: false } as CartState,
  reducers: {
    addItem: (state, action: PayloadAction<CartItem>) => {
      state.items.push(action.payload); // Immer handles immutability
    },
    removeItem: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter(i => i.id !== action.payload);
    },
  },
});

const store = configureStore({
  reducer: {
    cart: cartSlice.reducer,
  },
});

export const { addItem, removeItem } = cartSlice.actions;
export type RootState = ReturnType<typeof store.getState>;
// Component usage
function CartButton() {
  const dispatch = useDispatch();
  const itemCount = useSelector((state: RootState) => state.cart.items.length);

  return (
    <button onClick={() => dispatch(addItem(item))}>
      Add to Cart ({itemCount})
    </button>
  );
}

Use Redux when:

  • Large team/codebase
  • Need powerful DevTools
  • Complex async workflows (RTK Query)
  • Need middleware (logging, persistence)

Server State: A Different Beast

For data from APIs, use dedicated tools:

// TanStack Query (React Query)
function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return <Profile user={data} />;
}

Dont put server data in Redux/Zustand. TanStack Query handles:

  • Caching
  • Refetching
  • Loading states
  • Error handling
  • Optimistic updates

Quick Reference

| Solution | Use For | Avoid For | |----------|---------|-----------| | useState | Simple local state | Complex transitions | | useReducer | Complex local state | Global state | | Context | Theme, auth, i18n | Frequent updates | | Zustand | Global state, simple API | Huge apps | | Redux Toolkit | Large apps, complex flows | Simple apps | | TanStack Query | Server state | Client-only state |

My Recommendations

  1. Start with useState - Most state is local
  2. Lift state before going global - Often enough
  3. Use TanStack Query for server state - Always
  4. Pick Zustand for client global state - Unless you need Redux ecosystem
  5. Use Context sparingly - Theme, auth, locale only

Further Reading

The best state management is the least state management. Keep state local until you cant. Then pick the simplest tool that solves your problem.

© 2026 Tawan. All rights reserved.