Skip to main content
React Performance·Lesson 4 of 5

State Performance

State Colocation

The simplest and most effective performance optimization is putting state as close as possible to where it's used. State that lives too high in the tree causes unnecessary re-renders in unrelated subtrees.

// BAD: Search state in the root causes the entire app to re-render on every keystroke
function App() {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <div>
      <Header />
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <ExpensiveDataGrid /> {/* re-renders on every keystroke! */}
      <Footer />
    </div>
  );
}

// GOOD: Search state colocated with the search feature
function App() {
  return (
    <div>
      <Header />
      <SearchSection /> {/* state lives inside */}
      <ExpensiveDataGrid /> {/* unaffected by search */}
      <Footer />
    </div>
  );
}

function SearchSection() {
  const [searchQuery, setSearchQuery] = useState("");
  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <SearchResults query={searchQuery} />
    </div>
  );
}

The Context Re-render Problem

When a context value changes, every component that calls useContext for that context re-renders — even if it only uses a slice of the value that didn't change.

// BAD: One big context — changing theme re-renders components that only need user
const AppContext = createContext<{
  user: User;
  theme: string;
  notifications: Notification[];
} | null>(null);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User>(initialUser);
  const [theme, setTheme] = useState("light");
  const [notifications, setNotifications] = useState<Notification[]>([]);

  // New object on every render — every consumer re-renders
  const value = { user, theme, notifications };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

Splitting Contexts

Split unrelated concerns into separate contexts so consumers only subscribe to what they need:

const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<string>("light");
const NotificationContext = createContext<Notification[]>([]);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User>(initialUser);
  const [theme, setTheme] = useState("light");
  const [notifications, setNotifications] = useState<Notification[]>([]);

  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <NotificationContext.Provider value={notifications}>
          {children}
        </NotificationContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

// This component ONLY re-renders when theme changes
function ThemeIndicator() {
  const theme = useContext(ThemeContext);
  return <span>Current theme: {theme}</span>;
}

Stabilizing Context Values

Even with split contexts, creating new objects or arrays in the provider body causes re-renders:

// BAD: New object every render
function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  return (
    <AuthContext.Provider value={{ user, setUser }}>
      {children}
    </AuthContext.Provider>
  );
}

// GOOD: Memoize the context value
function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const value = useMemo(() => ({ user, setUser }), [user]);

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

External Stores with useSyncExternalStore

For high-frequency updates or complex state, external stores with selectors avoid the context re-render problem entirely:

import { useSyncExternalStore } from "react";

// A minimal store implementation
function createStore<T>(initialState: T) {
  let state = initialState;
  const listeners = new Set<()=> void>();

  return {
    getState: () => state,
    setState: (updater: (prev: T) => T) => {
      state = updater(state);
      listeners.forEach((listener) => listener());
    },
    subscribe: (listener: () => void) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

const store = createStore({ count: 0, name: "Alice" });

// This component ONLY re-renders when count changes
function Counter() {
  const count = useSyncExternalStore(
    store.subscribe,
    () => store.getState().count // selector
  );

  return <span>{count}</span>;
}

// This component ONLY re-renders when name changes
function NameDisplay() {
  const name = useSyncExternalStore(
    store.subscribe,
    () => store.getState().name // different selector
  );

  return <span>{name}</span>;
}

The Selector Pattern with Zustand

Libraries like Zustand make the selector pattern ergonomic:

import { create } from "zustand";

interface AppState {
  products: Product[];
  cart: CartItem[];
  addToCart: (product: Product) => void;
  removeFromCart: (id: string) => void;
}

const useStore = create<AppState>((set) => ({
  products: [],
  cart: [],
  addToCart: (product) =>
    set((state) => ({
      cart: [...state.cart, { ...product, quantity: 1 }],
    })),
  removeFromCart: (id) =>
    set((state) => ({
      cart: state.cart.filter((item) => item.id !== id),
    })),
}));

// Only re-renders when cart changes — immune to products updates
function CartBadge() {
  const cartCount = useStore((state) => state.cart.length);
  return <span className="badge">{cartCount}</span>;
}

// Only re-renders when products change
function ProductGrid() {
  const products = useStore((state) => state.products);
  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  );
}

useReducer for Complex State

When state transitions are complex, useReducer consolidates updates and avoids scattered setState calls:

type FormState = {
  values: Record<string, string>;
  errors: Record<string, string>;
  isSubmitting: boolean;
};

type FormAction =
  | { type: "SET_FIELD"; field: string; value: string }
  | { type: "SET_ERROR"; field: string; error: string }
  | { type: "SUBMIT" }
  | { type: "SUBMIT_SUCCESS" }
  | { type: "SUBMIT_ERROR"; errors: Record<string, string> };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "SET_FIELD":
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: "" },
      };
    case "SUBMIT":
      return { ...state, isSubmitting: true };
    case "SUBMIT_SUCCESS":
      return { values: {}, errors: {}, isSubmitting: false };
    case "SUBMIT_ERROR":
      return { ...state, errors: action.errors, isSubmitting: false };
    default:
      return state;
  }
}