Skip to content
intermediate Phase 7 · React Fundamentals

State Management Patterns

Manage global state with Context API, Redux Toolkit, Zustand, and server state with React Query.

1h 15m
0 problems
Topic Progress 0%

When to Use External State Management

When to Use External State Management

Decision Framework

Choosing the right state management solution depends on the scope of shared data, how frequently it updates, and how much boilerplate your team is willing to accept. Not every application needs Redux — in fact, most small-to-medium apps work perfectly with just React's built-in hooks.

Scenario Recommended Solution
Local UI state (form inputs, toggles, modals) useState
Shared state across a subtree of components useContext + useReducer
Global auth, theme, or notification state useContext + useReducer with a provider
Complex app with many slices and frequent updates Redux Toolkit
Simple global state with minimal boilerplate Zustand
Server data (API responses, caching, refetching) TanStack Query

The Prop Drilling Problem

When multiple distant components need the same piece of state, passing it through every intermediate component becomes painful and creates tight coupling:

// Without state management — prop drilling through 3 levels
function App() {
  const [user, setUser] = useState(null);
  const logout = () => setUser(null);

  return (
    <Layout>
      <Sidebar>
        <UserProfile user={user} onLogout={logout} />
      </Sidebar>
      <Main>
        <Dashboard user={user} />
        <Notifications userId={user?.id} />
      </Main>
    </Layout>
  );
}

// Layout and Sidebar receive user just to pass it down
function Layout({ children }) {
  return <div className="layout">{children}</div>;
}

Context Has Performance Limits

React Context re-renders ALL consumers whenever ANY provided value changes. This is fine for rarely-changing values like themes or locale, but causes performance problems for frequently updated state such as search input or cursor position in a large list.

// Bad: SearchContext re-renders the entire tree on every keystroke
const SearchContext = createContext('');

function SearchProvider({ children }) {
  const [query, setQuery] = useState('');
  // Every consumer re-renders when query changes
  return (
    <SearchContext.Provider value={{ query, setQuery }}>
      {children}
    </SearchContext.Provider>
  );
}

For frequently changing state, libraries like Zustand or Redux Toolkit provide selector-based subscriptions that only re-render components that actually read the changed slice of state.

Redux Toolkit

Redux Toolkit

Store Setup with TypeScript

Redux Toolkit is the official, recommended way to write Redux logic. It simplifies store configuration, reduces boilerplate, and includes Immer for immutable updates by default.

import { configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { apiSlice } from './api/apiSlice';
import authReducer from './features/auth/authSlice';
import cartReducer from './features/cart/cartSlice';

export const store = configureStore({
  reducer: {
    auth: authReducer,
    cart: cartReducer,
    [apiSlice.reducerPath]: apiSlice.reducer,
  },
  middleware: (getDefault) => getDefault().concat(apiSlice.middleware),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Feature Slice with Immer Mutations

createSlice generates action creators and action types automatically. The reducer uses Immer under the hood, so you can write mutations that produce immutable updates:

// features/cart/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  total: number;
}

const initialState: CartState = { items: [], total: 0 };

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addItem(state, action: PayloadAction<Omit<CartItem, 'quantity'>>) {
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        existing.quantity++;
      } else {
        state.items.push({ ...action.payload, quantity: 1 });
      }
      state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    },
    removeItem(state, action: PayloadAction<string>) {
      state.items = state.items.filter(i => i.id !== action.payload);
      state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    },
    clearCart() {
      return initialState;
    },
  },
});

export const { addItem, removeItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;

Async Thunks for API Calls

createAsyncThunk handles pending, fulfilled, and rejected states for async operations:

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

export const fetchProducts = createAsyncThunk(
  'products/fetchProducts',
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/products');
      if (!response.ok) throw new Error('Failed to fetch');
      return await response.json();
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const productsSlice = createSlice({
  name: 'products',
  initialState: {
    items: [],
    loading: 'idle',
    error: null as string | null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchProducts.pending, (state) => {
        state.loading = 'pending';
        state.error = null;
      })
      .addCase(fetchProducts.fulfilled, (state, action) => {
        state.loading = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchProducts.rejected, (state, action) => {
        state.loading = 'failed';
        state.error = action.payload as string;
      });
  },
});

Zustand for Lightweight State

Zustand for Lightweight State

Store Creation with Middleware

Zustand provides a minimal API for creating stores without providers or boilerplate. Middleware adds devtools integration and persistence with just a few lines:

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

interface AuthStore {
  user: User | null;
  token: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isAuthenticated: boolean;
}

export const useAuthStore = create<AuthStore>()(
  devtools(
    persist(
      (set, get) => ({
        user: null,
        token: null,
        get isAuthenticated() { return !!get().token; },
        login: async (email, password) => {
          const res = await fetch('/api/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, password }),
          });
          const { user, token } = await res.json();
          set({ user, token });
        },
        logout: () => set({ user: null, token: null }),
      }),
      { name: 'auth-storage' }
    ),
    { name: 'AuthStore' }
  )
);

Selector-Based Rendering

Zustand uses selectors to subscribe only to the specific pieces of state a component needs. Components only re-render when the selected value changes, unlike Context which re-renders all consumers:

function Navbar() {
  const user = useAuthStore(state => state.user);
  const logout = useAuthStore(state => state.logout);

  return (
    <nav>
      {user ? (
        <>
          <span>{user.name}</span>
          <button onClick={logout}>Logout</button>
        </>
      ) : (
        <a href="/login">Login</a>
      )}
    </nav>
  );
}

// This component only re-renders when items changes, not when user changes
function Cart() {
  const items = useAuthStore(state => state.items);
  const total = useAuthStore(state => state.total);
  return (
    <div>
      <p>{items.length} items — ${total.toFixed(2)}</p>
    </div>
  );
}

Zustand vs Redux Toolkit Comparison

Feature Zustand Redux Toolkit
Boilerplate Minimal — one create() call Moderate — slices, store config
Bundle size ~1.5KB gzipped ~11KB gzipped
DevTools Optional middleware plugin Built-in Redux DevTools
Middleware compose, persist, immer, devtools compose, thunk, saga, RTK Query
Learning curve Low — simple API Moderate — action/reducer paradigm
Server state Pair with TanStack Query RTK Query built-in

Immer Integration

Zustand works with Immer for nested state updates, similar to Redux Toolkit:

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface TodoStore {
  todos: { id: string; text: string; completed: boolean }[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
}

export const useTodoStore = create<TodoStore>()(
  immer((set) => ({
    todos: [],
    addTodo: (text) => set((state) => {
      state.todos.push({
        id: crypto.randomUUID(),
        text,
        completed: false,
      });
    }),
    toggleTodo: (id) => set((state) => {
      const todo = state.todos.find(t => t.id === id);
      if (todo) todo.completed = !todo.completed;
    }),
  }))
);

Server State with TanStack Query

Server State with TanStack Query

Setup and Configuration

TanStack Query (formerly React Query) manages server state — data fetched from APIs. It handles caching, background refetching, deduplication, and optimistic updates automatically. Client state (UI toggles, form inputs) stays in Zustand or Redux; server data lives in TanStack Query.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,  // data is fresh for 5 minutes
      retry: 2,
      refetchOnWindowFocus: true,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
    </QueryClientProvider>
  );
}

Custom Query and Mutation Hooks

Wrap all API calls in custom hooks to keep query logic reusable and consistent across your application:

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

function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json()),
    staleTime: 30000,
  });
}

function useUpdateUser() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (user: Partial<User> & { id: string }) =>
      fetch(`/api/users/${user.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(user),
      }).then(r => r.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

Usage with Loading and Error States

TanStack Query provides isLoading, isError, and data states so components handle all phases of a data fetch:

function UserList() {
  const { data: users, isLoading, error } = useUsers();
  const updateUser = useUpdateUser();

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

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name}
          <button
            onClick={() => updateUser.mutate({ id: user.id, name: 'New Name' })}
            disabled={updateUser.isPending}
          >
            {updateUser.isPending ? 'Saving...' : 'Rename'}
          </button>
        </li>
      ))}
    </ul>
  );
}

Key Insight: Client vs Server State

Keep a clear separation: client state (theme, form inputs, UI toggles) belongs in Zustand or Redux Toolkit. Server state (API responses, fetched data) belongs in TanStack Query. Storing server data in Redux creates duplicate cache management and complex synchronization logic that TanStack Query handles out of the box.

Quiz

1. Why does React Context cause performance problems for frequently updating state?

Question 1 options

2. What is the primary advantage of separating server state (TanStack Query) from client state (Zustand or Redux)?

Question 2 options

3. How does Zustand achieve fine-grained re-renders without providers?

Question 3 options

Flashcards

Question

When should you choose Zustand over Redux Toolkit for global state management?

Answer

Choose Zustand for smaller apps or teams that want minimal boilerplate, a smaller bundle (~1.5KB vs ~11KB), and a simpler API. Zustand is also preferred when you want fine-grained selector-based subscriptions without Providers. Choose Redux Toolkit when your app has many interconnected slices, you need built-in RTK Query for server state, your team already knows Redux patterns, or you want the full power of Redux DevTools and middleware ecosystem.

Question

What does Immer do in Redux Toolkit and Zustand, and why is it important?

Answer

Immer lets you write code that looks like direct mutations but produces immutable updates under the hood. Instead of manually spreading objects and creating new arrays, you can write `state.items.push(newItem)` or `state.user.name = 'Updated'`. Immer produces the next immutable state via structural sharing, so only the changed parts of the state tree are replaced, which keeps React's reconciliation efficient and your reducer code readable.

Question

Why should server state not be stored in Redux or Zustand?

Answer

Server state (API responses) has properties that client state does not — it can become stale, needs background refetching, requires caching and deduplication, and should be invalidated when mutations occur. TanStack Query handles all of this automatically. Storing server data in Redux or Zustand means manually implementing caching, stale detection, refetching logic, and optimistic updates — essentially rebuilding what TanStack Query already provides. Keep client state (UI toggles, form inputs) in Redux/Zustand and server state in TanStack Query.

Revision Notes

Key Takeaways

  • 1. Context re-renders ALL consumers on every value change — use it only for infrequently changing values like theme, locale, or auth. For frequently updated state, use Zustand or Redux Toolkit with selector-based subscriptions
  • 2. Redux Toolkit is the official recommendation for complex apps with many slices, built-in Immer for immutable updates, and RTK Query for server state synchronization
  • 3. Zustand provides a minimal API with ~1.5KB bundle size, selector-based re-renders, and middleware for devtools and persistence — ideal for simpler global state needs
  • 4. TanStack Query manages server state with automatic caching, background refetching, deduplication, and optimistic updates — keep server data out of Redux/Zustand
  • 5. Immer (used by Redux Toolkit and Zustand middleware) enables direct mutation syntax that produces immutable updates via structural sharing
  • 6. Always choose the simplest tool that meets your needs — useState for local state, Context for infrequent globals, Zustand for lightweight global state, Redux for complex multi-slice apps, TanStack Query for server data

Interview Tips

  • Explain the performance difference between Context and Zustand by describing how selector-based subscriptions prevent unnecessary re-renders compared to Context's all-consumers approach
  • Describe a concrete scenario where storing server data in Redux would require extra work that TanStack Query handles automatically (caching, stale detection, refetching on window focus)
  • Compare Redux Toolkit and Zustand on bundle size, boilerplate, and developer experience — show you understand tradeoffs rather than just picking one
  • Explain how Immer works under the hood and why structural sharing is important for React's reconciliation performance
  • Walk through setting up a Zustand store with persist and devtools middleware, explaining what each middleware adds

Cheat Sheet

Context API

createContext + useContext — best for infrequent updates (theme, auth). All consumers re-render on value change

Redux Toolkit

createSlice for reducers, createAsyncThunk for async, configureStore for setup. Uses Immer for immutable updates

Zustand

create((set, get) => ({...})) — selector-based subscriptions, no Provider needed, ~1.5KB bundle

TanStack Query

useQuery for fetching, useMutation for writes. Manages cache, refetching, deduplication, and optimistic updates

Immer

Lets you write mutations that produce immutable updates. Used by Redux Toolkit and Zustand middleware

State Separation

Client state (UI, forms) in Zustand/Redux. Server state (API data) in TanStack Query

Redux Middleware

getDefaultMiddleware().concat(customMiddleware) — thunk, saga, RTK Query, logger

Zustand Selectors

useStore(state => state.slice) — component re-renders only when selected value changes

RTK Query

createApi with baseQuery, endpoints, and tagTypes — built-in server state management in Redux