React Hooks Complete Guide 2026 (React 19 Update)

Master React 19 Hooks: useState, use(), useEffect alternatives, useRef, useMemo, useCallback, custom hooks, and concurrent features with production examples.

Inzimam Ul Haq
Inzimam Ul Haq
· 23 min read · Updated
React code on a computer screen representing React Hooks development

React Hooks are functions that let you use state and lifecycle features in functional components. Since their introduction in React 16.8, they’ve become the standard API for building React applications. React’s Virtual DOM and reconciliation algorithm work with hooks to efficiently update the UI-when hook state changes, React calculates the minimal DOM updates needed. With React 19, hooks have evolved further-the new use() hook simplifies data fetching, and the community has shifted away from useEffect for many common patterns.

This guide covers React 19.x (March 2026). Check react.dev for the latest API changes.

This guide covers every hook you need: from foundational hooks like useState, to the new use() API, performance optimization with useMemo, and when you actually need useEffect (spoiler: less often than you think). Each section includes production-ready examples and links to official documentation. If you’re comparing React with other frameworks, check out our Angular vs React deep dive.

TL;DR - React Hooks in 2026:

  • Use useState for simple state, useReducer for complex state
  • Use use() + Suspense for data fetching (not useEffect)
  • Use useRef for DOM access and mutable values that don’t trigger re-renders
  • Use useMemo/useCallback sparingly-React Compiler will handle most cases
  • Server Components can’t use hooks-add 'use client' for interactivity

React Hooks Quick Reference

HookPurposeWhen to UseDocs
useStateAdd state to componentsSimple state: booleans, strings, numbersReference
useRead Promises/ContextData fetching, conditional contextReference
useEffectSync with external systemsSubscriptions, event listeners, non-React codeReference
useContextAccess context valuesTheme, auth, or any shared stateReference
useReducerComplex state logicMultiple sub-values, state machinesReference
useRefMutable referencesDOM access, storing values without re-rendersReference
useMemoMemoize valuesExpensive calculationsReference
useCallbackMemoize functionsCallbacks passed to optimized childrenReference
useLayoutEffectSynchronous DOM effectsMeasurements before browser paintReference
useImperativeHandleCustomize ref exposureExposing methods to parent componentsReference
useDebugValueDevTools labelsDebugging custom hooksReference
useSyncExternalStoreSubscribe to external storesRedux, Zustand, browser APIsReference
useInsertionEffectInject styles before paintCSS-in-JS libraries onlyReference

React Hooks Visual Overview

Which Hook Should I Use? Decision Flowchart

React Hooks Decision Flowchart - Which hook should I use for state, data fetching, side effects, DOM access, and context

React 19 shift: For data fetching, prefer use() with Suspense or libraries like TanStack Query over useEffect. Reserve useEffect for synchronizing with external systems (subscriptions, event listeners, non-React code).

The Rules of Hooks

React enforces two rules that you must follow (official docs):

  1. Only call Hooks at the top level. Never call Hooks inside loops, conditions, or nested functions.
  2. Only call Hooks from React functions. Call them from functional components or custom Hooks-not regular JavaScript functions.

Why these rules exist: React stores Hook state in a linked list attached to each component’s fiber node. Each Hook call corresponds to a position in this list, matched by call order. If Hooks are called conditionally, the order changes between renders, causing state to become misaligned.

// ❌ Wrong: Hook inside condition - breaks call order
function Profile({ isLoggedIn }) {
  if (isLoggedIn) {
    const [user, setUser] = useState(null); // Position varies!
  }
  const [theme, setTheme] = useState("dark"); // Wrong state assigned
}

// ✅ Correct: Condition inside Hook logic
function Profile({ isLoggedIn }) {
  const [user, setUser] = useState(null); // Always position 0
  const [theme, setTheme] = useState("dark"); // Always position 1

  // Conditional logic after Hook calls
  if (!isLoggedIn) return <LoginPrompt />;
  return <Dashboard user={user} theme={theme} />;
}

Install eslint-plugin-react-hooks to catch violations automatically:

npm install eslint-plugin-react-hooks --save-dev

What is useState in React?

useState is a React Hook that adds state management to functional components. It accepts an initial value and returns an array containing the current state and a setter function.

AspectDetails
PurposeAdd reactive state to functional components
Syntaxconst [value, setValue] = useState(initialValue)
ReturnsArray: [currentState, setterFunction]
Re-rendersYes, when setter is called with different value
When to useSimple state: booleans, strings, numbers, independent values
import { useState } from "react";

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

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

Functional Updates: Avoiding Stale State

When the next state depends on the previous state, pass a function to the setter:

// ❌ May cause bugs with stale state in async handlers
setCount(count + 1);
setCount(count + 1); // Both read same 'count', only increments by 1

// ✅ Guaranteed to use latest state
setCount((prev) => prev + 1);
setCount((prev) => prev + 1); // Correctly increments by 2

Lazy Initialization

For expensive initial values, pass a function to avoid recalculating on every render:

// ❌ Runs expensive computation every render
const [data, setData] = useState(expensiveComputation());

// ✅ Only runs once on mount
const [data, setData] = useState(() => expensiveComputation());

The use() Hook: React 19’s Game Changer

use() is a React API that reads the value of a Promise or Context. Unlike traditional hooks, use() can be called inside loops and conditionals. It integrates with Suspense to handle loading states declaratively.

AspectDetails
PurposeRead Promises and Context values
Syntaxconst value = use(promiseOrContext)
Can call in loops/conditions✅ Yes (unlike other hooks)
Suspense integration✅ Suspends until Promise resolves
When to useData fetching, conditional context reading

Data Fetching with use() and Suspense

import { use, Suspense } from "react";

// Create the promise outside the component (or in a parent)
function fetchUser(id) {
  return fetch(`/api/users/${id}`).then((res) => res.json());
}

function UserProfile({ userPromise }) {
  // use() suspends until the promise resolves
  const user = use(userPromise);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// Parent component handles Suspense boundary
function App({ userId }) {
  const userPromise = fetchUser(userId);

  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

Conditional Context with use()

Unlike useContext, use() can read context conditionally:

import { use } from "react";

function StatusMessage({ showTheme }) {
  // ✅ Can call use() inside a condition
  if (showTheme) {
    const theme = use(ThemeContext);
    return <p style={{ color: theme.primary }}>Themed message</p>;
  }

  return <p>Default message</p>;
}

use() vs useEffect for Data Fetching

Patternuse() + SuspenseuseEffect
Loading stateHandled by Suspense boundaryManual isLoading state
Error handlingError BoundaryManual error state
Race conditionsAutomatic (Suspense handles)Manual cleanup required
Code complexityMinimalSignificant boilerplate
Server ComponentsWorks seamlesslyClient-only

When to use each:

  • use(): New projects, data fetching, reading context conditionally
  • useEffect: Subscriptions, event listeners, synchronizing with non-React code

You Might Not Need useEffect

The React community has shifted away from useEffect for many patterns. As the official docs state: “Effects are an escape hatch from the React paradigm.”

Common useEffect Anti-Patterns (And What to Use Instead)

Anti-PatternProblemBetter Approach
Data fetchingRace conditions, cleanup complexityuse() + Suspense, TanStack Query, Server Components
Derived stateUnnecessary re-rendersCompute during render, useMemo
Resetting state on prop changeEffect runs after renderkey prop to reset component
Event handlersStale closuresEvent handlers directly
Syncing with parentProp drilling issuesLift state up, context

❌ Anti-Pattern: Derived State in useEffect

// ❌ Bad: useEffect for derived state
function FilteredList({ items, filter }) {
  const [filteredItems, setFilteredItems] = useState([]);

  useEffect(() => {
    setFilteredItems(items.filter((i) => i.includes(filter)));
  }, [items, filter]);

  return <List items={filteredItems} />;
}

// ✅ Good: Compute during render
function FilteredList({ items, filter }) {
  const filteredItems = items.filter((i) => i.includes(filter));
  return <List items={filteredItems} />;
}

// ✅ Good: useMemo for expensive computations
function FilteredList({ items, filter }) {
  const filteredItems = useMemo(
    () => items.filter((i) => i.includes(filter)),
    [items, filter],
  );
  return <List items={filteredItems} />;
}

❌ Anti-Pattern: Resetting State on Prop Change

// ❌ Bad: useEffect to reset state
function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    setUser(null); // Reset on userId change
    fetchUser(userId).then(setUser);
  }, [userId]);
}

// ✅ Good: Use key to reset entire component
function App({ userId }) {
  return <Profile key={userId} userId={userId} />;
}

When You Actually Need useEffect

useEffect is for synchronizing with external systems-code that runs outside React’s rendering cycle. Use it for:

  1. Subscriptions (WebSocket, browser APIs)
  2. Event listeners (window resize, scroll, keyboard)
  3. Third-party libraries (D3, maps, analytics)
  4. Manual DOM manipulation (focus, measurements)

useEffect Execution Model

useEffect Execution Model - Component renders, DOM updates, browser paints, then useEffect runs with cleanup

Dependency Array Behavior

Dependency ArrayWhen Effect RunsUse Case
[dep1, dep2]When any dependency changesMost effects
[] (empty)Once on mount, cleanup on unmountSubscriptions, event listeners
OmittedAfter every renderRarely correct-usually a bug

Legitimate useEffect Example: WebSocket Subscription

import { useState, useEffect } from "react";

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    // Connect to external system (WebSocket)
    const connection = createConnection(roomId);

    connection.on("message", (msg) => {
      setMessages((prev) => [...prev, msg]);
    });

    connection.connect();

    // Cleanup: disconnect when roomId changes or component unmounts
    return () => {
      connection.disconnect();
    };
  }, [roomId]);

  return <MessageList messages={messages} />;
}

Legitimate useEffect Example: Window Event Listener

import { useState, useEffect } from "react";

function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    function handleResize() {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    }

    handleResize(); // Set initial size
    window.addEventListener("resize", handleResize);

    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return size;
}

Handling Race Conditions in useEffect

When fetching data in useEffect, rapid prop changes can cause race conditions-where an older request completes after a newer one, showing stale data.

// ❌ Bug: Race condition - older fetch might resolve after newer one
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser); // No protection against stale responses
  }, [userId]);

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

// ✅ Fix: Use a cancelled flag to ignore stale responses
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false;

    fetchUser(userId).then((data) => {
      if (!cancelled) {
        setUser(data); // Only update if this effect is still active
      }
    });

    return () => {
      cancelled = true; // Mark as stale when userId changes or unmount
    };
  }, [userId]);

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

// ✅ Better: Use AbortController to cancel the actual request
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => res.json())
      .then(setUser)
      .catch((err) => {
        if (err.name !== "AbortError") {
          console.error(err);
        }
      });

    return () => controller.abort();
  }, [userId]);

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

Best practice: For data fetching, prefer use() + Suspense or TanStack Query-they handle race conditions automatically.

What is useRef in React?

useRef is a React Hook that creates a mutable reference object persisting across renders. Unlike state, changing a ref does not trigger a re-render.

useStateuseRef
Triggers re-render on changeNo re-render on change
Returns [value, setter]Returns { current: value }
For UI state that affects renderingFor DOM refs, timers, previous values
Immutable between rendersMutable anytime

Use Case 1: Accessing DOM Elements

import { useRef, useEffect } from "react";

function SearchInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    // Focus input on mount
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} type="search" placeholder="Search..." />;
}

Use Case 2: Storing Mutable Values (Timer IDs)

import { useRef, useState, useEffect } from "react";

function Stopwatch() {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef(null); // Persists across renders

  useEffect(() => {
    if (isRunning) {
      intervalRef.current = setInterval(() => {
        setTime((t) => t + 10);
      }, 10);
    }
    return () => clearInterval(intervalRef.current);
  }, [isRunning]);

  const reset = () => {
    clearInterval(intervalRef.current);
    setIsRunning(false);
    setTime(0);
  };

  return (
    <div>
      <span>{(time / 1000).toFixed(2)}s</span>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? "Stop" : "Start"}
      </button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Use Case 3: Tracking Previous Values

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current; // Returns value from previous render
}

// Usage
function PriceDisplay({ price }) {
  const prevPrice = usePrevious(price);
  const trend = price > prevPrice ? "📈" : price < prevPrice ? "📉" : "";

  return (
    <span>
      {trend} ${price}
    </span>
  );
}

What is useContext in React?

useContext is a React Hook that reads and subscribes to context from any component. It eliminates prop drilling by providing direct access to shared values anywhere in the component tree.

import { createContext, useContext, useState } from "react";

// 1. Create context with default value
const AuthContext = createContext(null);

// 2. Provider component (typically at app root)
export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  const login = async (credentials) => {
    const user = await api.login(credentials);
    setUser(user);
  };

  const logout = () => {
    setUser(null);
  };

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

// 3. Custom hook for cleaner consumption
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within AuthProvider");
  }
  return context;
}

// 4. Usage in any nested component
function UserMenu() {
  const { user, logout } = useAuth();

  if (!user) return <LoginButton />;
  return (
    <div>
      <span>Welcome, {user.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

Performance note: All components consuming a context re-render when the context value changes. For large apps, consider splitting contexts or using state management libraries. For scalable state management beyond Context, compare Redux vs Zustand vs Jotai. When building reusable components that rely on context, establishing a design system helps maintain consistency.

useState vs useReducer: When to Use Each

useReducer is a React Hook for managing complex state logic through a reducer function. It’s inspired by Redux and excels when state has multiple sub-values or complex update logic.

CriteriauseStateuseReducer
State shapePrimitive or simple objectObject with multiple related fields
Update logicIndependent, simple updatesRelated updates, state machines
Next state depends onNew value directlyPrevious state + action type
TestingInline, harder to isolateReducer is pure function, easy to test
DebuggingScattered updatesCentralized, action-based logging

Production Example: Multi-Step Form with Validation

import { useReducer } from "react";

// State shape
const initialState = {
  step: 1,
  data: { name: "", email: "", plan: "free" },
  errors: {},
  isSubmitting: false,
};

// Reducer: all state transitions in one place
function formReducer(state, action) {
  switch (action.type) {
    case "SET_FIELD":
      return {
        ...state,
        data: { ...state.data, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: null }, // Clear error on edit
      };

    case "SET_ERRORS":
      return { ...state, errors: action.errors };

    case "NEXT_STEP":
      return { ...state, step: state.step + 1 };

    case "PREV_STEP":
      return { ...state, step: state.step - 1 };

    case "SUBMIT_START":
      return { ...state, isSubmitting: true };

    case "SUBMIT_SUCCESS":
      return { ...initialState, step: 4 }; // Reset to success state

    case "SUBMIT_ERROR":
      return {
        ...state,
        isSubmitting: false,
        errors: { submit: action.error },
      };

    default:
      return state;
  }
}

function SignupWizard() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  const { step, data, errors, isSubmitting } = state;

  const validateStep = () => {
    const newErrors = {};
    if (step === 1 && !data.name.trim()) {
      newErrors.name = "Name is required";
    }
    if (step === 2 && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
      newErrors.email = "Valid email required";
    }

    if (Object.keys(newErrors).length > 0) {
      dispatch({ type: "SET_ERRORS", errors: newErrors });
      return false;
    }
    return true;
  };

  const handleNext = () => {
    if (validateStep()) {
      dispatch({ type: "NEXT_STEP" });
    }
  };

  const handleSubmit = async () => {
    if (!validateStep()) return;

    dispatch({ type: "SUBMIT_START" });
    try {
      await api.createAccount(data);
      dispatch({ type: "SUBMIT_SUCCESS" });
    } catch (err) {
      dispatch({ type: "SUBMIT_ERROR", error: err.message });
    }
  };

  return (
    <form>
      {step === 1 && (
        <input
          value={data.name}
          onChange={(e) =>
            dispatch({
              type: "SET_FIELD",
              field: "name",
              value: e.target.value,
            })
          }
          placeholder="Name"
        />
      )}
      {step === 2 && (
        <input
          value={data.email}
          onChange={(e) =>
            dispatch({
              type: "SET_FIELD",
              field: "email",
              value: e.target.value,
            })
          }
          placeholder="Email"
        />
      )}
      {step === 3 && (
        <select
          value={data.plan}
          onChange={(e) =>
            dispatch({
              type: "SET_FIELD",
              field: "plan",
              value: e.target.value,
            })
          }
        >
          <option value="free">Free</option>
          <option value="pro">Pro ($10/mo)</option>
        </select>
      )}

      {errors.submit && <p className="error">{errors.submit}</p>}

      <div>
        {step > 1 && (
          <button type="button" onClick={() => dispatch({ type: "PREV_STEP" })}>
            Back
          </button>
        )}
        {step < 3 ? (
          <button type="button" onClick={handleNext}>
            Next
          </button>
        ) : (
          <button type="button" onClick={handleSubmit} disabled={isSubmitting}>
            {isSubmitting ? "Submitting..." : "Create Account"}
          </button>
        )}
      </div>
    </form>
  );
}

Why useReducer wins here:

  • All state transitions are explicit and centralized
  • Easy to add logging: console.log('Action:', action)
  • Reducer is a pure function-easy to unit test
  • Complex validation logic stays organized

useMemo vs useCallback: Performance Optimization

Both hooks prevent unnecessary recalculations by memoizing values between renders. They’re part of React’s performance toolkit alongside React.memo.

HookWhat it MemoizesReturnsPrimary Use Case
useMemoComputed valuesThe cached resultExpensive calculations, derived data
useCallbackFunction definitionsThe cached functionCallbacks passed to memo children

What is useMemo?

useMemo caches the result of a calculation between re-renders. React only recalculates when dependencies change.

import { useMemo, useState } from "react";

function ProductList({ products, filters }) {
  const [sortOrder, setSortOrder] = useState("name");

  // Only recalculates when products, filters, or sortOrder change
  const displayedProducts = useMemo(() => {
    console.log("Filtering and sorting..."); // See when this runs

    return products
      .filter((p) => {
        if (filters.category && p.category !== filters.category) return false;
        if (filters.minPrice && p.price < filters.minPrice) return false;
        if (filters.maxPrice && p.price > filters.maxPrice) return false;
        return true;
      })
      .sort((a, b) => {
        if (sortOrder === "name") return a.name.localeCompare(b.name);
        if (sortOrder === "price") return a.price - b.price;
        return 0;
      });
  }, [products, filters, sortOrder]);

  return (
    <ul>
      {displayedProducts.map((p) => (
        <li key={p.id}>
          {p.name} - ${p.price}
        </li>
      ))}
    </ul>
  );
}

What is useCallback?

useCallback caches a function definition between re-renders. This is critical when passing callbacks to components wrapped in React.memo.

import { useCallback, useState, memo } from "react";

// Child wrapped in memo - only re-renders if props change
const ExpensiveList = memo(function ExpensiveList({ items, onItemClick }) {
  console.log("ExpensiveList rendered");
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id} onClick={() => onItemClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

function Dashboard({ items }) {
  const [selectedId, setSelectedId] = useState(null);
  const [filter, setFilter] = useState("");

  // Without useCallback: new function every render → ExpensiveList re-renders
  // With useCallback: same reference → ExpensiveList skips re-render
  const handleItemClick = useCallback((id) => {
    setSelectedId(id);
    analytics.track("item_selected", { id });
  }, []); // Empty deps: function never changes

  // This state change won't re-render ExpensiveList
  return (
    <div>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="Filter..."
      />
      <ExpensiveList items={items} onItemClick={handleItemClick} />
      {selectedId && <ItemDetail id={selectedId} />}
    </div>
  );
}

When NOT to Use These Hooks

Don’t wrap everything. Memoization has overhead (memory + comparison cost). Use them when:

  • ✅ Calculation is genuinely expensive (filtering 10,000+ items)
  • ✅ Passing callbacks to memo-wrapped children
  • ✅ Value is a dependency of another Hook
  • ❌ Simple calculations (adding two numbers)
  • ❌ Components that always re-render anyway
  • ❌ Premature optimization without measuring

Measure first with React DevTools Profiler before adding memoization.

The Future: React Compiler (React Forget)

The React Compiler automatically memoizes your components and hooks. When enabled, you won’t need to manually write useMemo, useCallback, or React.memo in most cases-the compiler handles it.

// Before React Compiler: manual memoization required
function ProductList({ products, onSelect }) {
  const sorted = useMemo(
    () => [...products].sort((a, b) => a.price - b.price),
    [products],
  );

  const handleSelect = useCallback(
    (id) => {
      onSelect(id);
    },
    [onSelect],
  );

  return <List items={sorted} onItemClick={handleSelect} />;
}

// With React Compiler: write naturally, compiler optimizes
function ProductList({ products, onSelect }) {
  const sorted = [...products].sort((a, b) => a.price - b.price);

  const handleSelect = (id) => {
    onSelect(id);
  };

  return <List items={sorted} onItemClick={handleSelect} />;
}

Current status: React Compiler is in beta and powers instagram.com. Check react.dev/learn/react-compiler for adoption guidance.

Until you adopt the compiler: Continue using useMemo and useCallback for performance-critical paths. For a deeper dive into React performance, see our complete performance optimization guide.

useLayoutEffect vs useEffect: What’s the Difference?

HookFiresUse Case
useEffectAfter browser paint (async)Data fetching, subscriptions, analytics
useLayoutEffectBefore browser paint (sync)DOM measurements, scroll position, tooltips

useLayoutEffect fires synchronously after DOM mutations but before the browser paints. Use it when you need to read layout (e.g., getBoundingClientRect) or mutate the DOM before the user sees the update.

useEffect:       Render → Paint → Effect runs (async)
useLayoutEffect: Render → Effect runs → Paint (sync, blocks paint)
import { useLayoutEffect, useRef, useState } from "react";

function Tooltip({ children, content }) {
  const ref = useRef();
  const [position, setPosition] = useState({ top: 0, left: 0 });

  // Measure before paint to avoid tooltip "jumping"
  useLayoutEffect(() => {
    const rect = ref.current.getBoundingClientRect();
    setPosition({
      top: rect.bottom + 8,
      left: rect.left + rect.width / 2,
    });
  }, []);

  return (
    <>
      <span ref={ref}>{children}</span>
      <div
        className="tooltip"
        style={{ position: "fixed", top: position.top, left: position.left }}
      >
        {content}
      </div>
    </>
  );
}

Warning: useLayoutEffect blocks the browser from painting. Keep logic fast to avoid jank. For SSR, it logs warnings-use useEffect or check typeof window !== 'undefined'.

The Stale Closure Problem

A stale closure occurs when a function captures an outdated value from a previous render. This is the most common source of bugs with Hooks.

The Problem

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

  useEffect(() => {
    const id = setInterval(() => {
      // ❌ Bug: 'count' is always 0 (captured from first render)
      console.log("Count:", count); // Always logs 0
      setCount(count + 1); // Always sets to 1
    }, 1000);

    return () => clearInterval(id);
  }, []); // Empty deps = closure captures initial count forever

  return <p>{count}</p>; // Shows 1, never increments
}

Why it happens: The effect runs once (empty deps). The count variable inside the interval callback is the value from that first render (0). It never sees updated values.

Three Solutions

1. Add to dependency array (effect re-runs, new closure each time):

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]); // Re-runs every time count changes

2. Use functional update (recommended for this case):

useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1); // Always uses current value
  }, 1000);
  return () => clearInterval(id);
}, []); // Safe: no external dependencies needed

3. Use useRef for mutable values:

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

  // Keep ref in sync
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const id = setInterval(() => {
      console.log("Current count:", countRef.current); // Always current
      setCount((c) => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

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

Detecting Stale Closures

The eslint-plugin-react-hooks exhaustive-deps rule catches most cases:

// eslint.config.js (ESLint v9+ flat config)
import reactHooks from "eslint-plugin-react-hooks";

export default [
  {
    plugins: { "react-hooks": reactHooks },
    rules: {
      "react-hooks/rules-of-hooks": "error",
      "react-hooks/exhaustive-deps": "warn",
    },
  },
];

Additional Hooks: useImperativeHandle and useDebugValue

useImperativeHandle

useImperativeHandle customizes the instance value exposed to parent components when using ref. Use it to expose specific methods rather than the entire DOM node.

import { useRef, useImperativeHandle } from "react";

function VideoPlayer({ src, ref }) {
  const videoRef = useRef();

  // Expose only play/pause methods, not the entire video element
  useImperativeHandle(
    ref,
    () => ({
      play: () => videoRef.current.play(),
      pause: () => videoRef.current.pause(),
      seek: (time) => {
        videoRef.current.currentTime = time;
      },
    }),
    [],
  );

  return <video ref={videoRef} src={src} />;
}

// Parent component
function App() {
  const playerRef = useRef();

  return (
    <div>
      <VideoPlayer ref={playerRef} src="/video.mp4" />
      <button onClick={() => playerRef.current.play()}>Play</button>
      <button onClick={() => playerRef.current.pause()}>Pause</button>
      <button onClick={() => playerRef.current.seek(30)}>Skip to 0:30</button>
    </div>
  );
}

React 19 update: forwardRef is no longer needed. Components receive ref as a regular prop, simplifying the API. The useImperativeHandle hook still works the same way to customize what the ref exposes.

useDebugValue

useDebugValue displays a label in React DevTools for custom Hooks.

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  // Shows "OnlineStatus: Online" or "OnlineStatus: Offline" in DevTools
  useDebugValue(isOnline ? "Online" : "Offline");

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return isOnline;
}

For expensive formatting, pass a formatter function (only called when DevTools is open):

useDebugValue(date, (date) => date.toISOString());

useSyncExternalStore: Subscribing to External Data

useSyncExternalStore subscribes to an external store in a way that’s compatible with concurrent rendering. Use it when you need to read data from sources outside React-like browser APIs, third-party state libraries, or custom stores.

AspectDetails
PurposeSubscribe to external data sources safely
SyntaxuseSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
Concurrent-safe✅ Prevents tearing (inconsistent UI)
When to useBrowser APIs, Redux/Zustand internals, custom stores

Why not just useEffect? In concurrent rendering, useEffect subscriptions can cause “tearing”-where different parts of the UI show inconsistent data. useSyncExternalStore guarantees consistency.

Example: Online Status Hook

import { useSyncExternalStore } from "react";

function subscribe(callback) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

function getServerSnapshot() {
  return true; // Assume online during SSR
}

function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

// Usage
function StatusIndicator() {
  const isOnline = useOnlineStatus();
  return <span>{isOnline ? "🟢 Online" : "🔴 Offline"}</span>;
}

Example: Custom Global Store

import { useSyncExternalStore, useCallback } from "react";

// Simple store implementation
function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();

  return {
    getState: () => state,
    setState: (newState) => {
      state = typeof newState === "function" ? newState(state) : newState;
      listeners.forEach((listener) => listener());
    },
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

const counterStore = createStore({ count: 0 });

// Hook to use the store
function useStore(store, selector) {
  const getSnapshot = useCallback(
    () => selector(store.getState()),
    [store, selector],
  );
  return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}

// Usage
function Counter() {
  const count = useStore(counterStore, (state) => state.count);

  return (
    <div>
      <p>Count: {count}</p>
      <button
        onClick={() => counterStore.setState((s) => ({ count: s.count + 1 }))}
      >
        Increment
      </button>
    </div>
  );
}

When to use useSyncExternalStore:

  • ✅ Subscribing to browser APIs (online status, media queries, localStorage)
  • ✅ Building state management libraries
  • ✅ Integrating with non-React data sources
  • ❌ Regular React state (use useState or useReducer)
  • ❌ Server data (use use() + Suspense or TanStack Query)

Note: Most developers won’t use useSyncExternalStore directly-libraries like Redux, Zustand, and Jotai use it internally. But understanding it helps when building custom integrations.

useInsertionEffect: For CSS-in-JS Libraries Only

useInsertionEffect fires before any DOM mutations. It’s designed exclusively for CSS-in-JS library authors (Styled Components, Emotion) to inject <style> tags at the right time.

useInsertionEffect: Effect runs → DOM mutations → useLayoutEffect → Paint → useEffect

You probably don’t need this hook. Unless you’re building a CSS-in-JS library, use useEffect or useLayoutEffect instead. The React docs explicitly state: “useInsertionEffect is for CSS-in-JS library authors.”

// Only for CSS-in-JS library internals
useInsertionEffect(() => {
  // Inject <style> tags here, before DOM reads
}, []);

React 19 Hooks and APIs

React 19 brings significant improvements to hooks, including the new use() API (covered above) and enhanced concurrent features. Here’s the complete picture:

React 19 Hooks: Quick Decision Guide

If you need to…Use this hook
Fetch datause() + Suspense
Show optimistic UIuseOptimistic
Track form submissionuseFormStatus
Defer expensive rendersuseTransition or useDeferredValue
Generate SSR-safe IDsuseId

All React 19 Hooks

Hook/APIPurposeWhen to Use
useRead Promises/ContextData fetching, conditional context
useFormStatusForm submission stateDisable buttons, show spinners during submit
useOptimisticOptimistic UI updatesInstant feedback before server confirms
useActionStateForm action stateServer Actions, progressive enhancement
useTransitionMark updates as non-urgentLarge list filtering, tab switching
useDeferredValueDefer updating a valueDerived values from urgent state
useIdGenerate unique IDsForm labels, ARIA attributes (SSR-safe)

React 19 New Features Deep Dive

useFormStatus: Form Submission State (React 19)

useFormStatus returns the pending status of the parent form. Use it to disable buttons and show loading states during form submission.

import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "Submitting..." : "Submit"}
    </button>
  );
}

function ContactForm() {
  async function handleSubmit(formData) {
    "use server";
    await saveContact(formData);
  }

  return (
    <form action={handleSubmit}>
      <input name="email" type="email" required />
      <input name="message" required />
      <SubmitButton /> {/* Automatically knows form is submitting */}
    </form>
  );
}

Key points:

  • Must be used in a component that’s a child of <form>
  • Returns { pending, data, method, action }
  • Works with Server Actions and client-side form handling

useOptimistic: Instant UI Feedback (React 19)

useOptimistic shows a different state while an async action is in progress. The UI updates immediately, then reconciles when the action completes.

import { useOptimistic } from "react";

function TodoList({ todos, addTodo }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo) => [...state, { ...newTodo, pending: true }],
  );

  async function handleSubmit(formData) {
    const newTodo = { id: Date.now(), text: formData.get("text") };

    // Immediately show the new todo (optimistic)
    addOptimisticTodo(newTodo);

    // Actually save it (this might fail)
    await addTodo(newTodo);
  }

  return (
    <form action={handleSubmit}>
      <input name="text" />
      <button type="submit">Add</button>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
            {todo.text}
          </li>
        ))}
      </ul>
    </form>
  );
}

useTransition: Keep UI Responsive During Heavy Updates

import { useState, useTransition } from "react";

function SearchResults({ items }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    // Urgent: update input immediately (user sees typing)
    setQuery(value);

    // Non-urgent: can be interrupted if user keeps typing
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase()),
      );
      setFilteredItems(filtered);
    });
  };

  return (
    <div>
      <input
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search 10,000 items..."
      />
      {isPending && <span className="spinner">Filtering...</span>}
      <ul style={{ opacity: isPending ? 0.7 : 1 }}>
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

useDeferredValue: Defer Expensive Derived Values

import { useState, useDeferredValue, memo } from "react";

const SlowList = memo(function SlowList({ query }) {
  // Expensive render
  const items = generateItems(query); // Imagine 10,000 items
  return (
    <ul>
      {items.map((i) => (
        <li key={i}>{i}</li>
      ))}
    </ul>
  );
});

function Search() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);

  // Input updates immediately, SlowList uses deferred (stale) value
  const isStale = query !== deferredQuery;

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.7 : 1 }}>
        <SlowList query={deferredQuery} />
      </div>
    </div>
  );
}

useId: SSR-Safe Unique IDs

import { useId } from "react";

function FormField({ label }) {
  const id = useId(); // Generates same ID on server and client

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </div>
  );
}

// For multiple related IDs
function PasswordField() {
  const id = useId();

  return (
    <>
      <label htmlFor={`${id}-password`}>Password</label>
      <input
        id={`${id}-password`}
        type="password"
        aria-describedby={`${id}-hint`}
      />
      <p id={`${id}-hint`}>Must be 8+ characters</p>
    </>
  );
}

Hooks and Server Components (React 19)

Server Components cannot use Hooks. They run on the server without React’s client-side runtime. This is a fundamental architectural decision in React 19’s App Router.

Component TypeCan Use Hooks?Runs OnBest For
Server Component (default)❌ NoServer onlyData fetching, static content
Client Component ('use client')✅ YesClient (hydrated)Interactivity, state, effects
// app/page.js - Server Component (no hooks, direct async/await)
export default async function Page() {
  // Fetch data directly-no useEffect needed!
  const user = await fetchUser();
  const posts = await fetchPosts(user.id);

  return (
    <div>
      <h1>{user.name}</h1>
      <ClientPostList initialPosts={posts} />
    </div>
  );
}

// app/ClientPostList.js - Client Component (hooks allowed)
("use client");

import { useState } from "react";

export function ClientPostList({ initialPosts }) {
  const [posts, setPosts] = useState(initialPosts);
  const [filter, setFilter] = useState("");

  const filteredPosts = posts.filter((p) =>
    p.title.toLowerCase().includes(filter.toLowerCase()),
  );

  return (
    <div>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="Filter posts..."
      />
      <ul>
        {filteredPosts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

React 19 pattern: Fetch data in Server Components (no hooks needed), pass to Client Components for interactivity. This eliminates most useEffect data fetching patterns. For a deeper dive into this architecture, see our React Server Components Practical Guide. For a full comparison of when to use each framework, see React vs Next.js vs Astro.

Building Custom Hooks

A custom Hook is a JavaScript function starting with “use” that calls other Hooks. Custom Hooks let you extract and share stateful logic between components without changing component hierarchy.

Custom Hook: useFetch with Caching

import { useState, useEffect, useRef } from "react";

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [status, setStatus] = useState("idle");

  // Cache responses
  const cache = useRef(new Map());

  useEffect(() => {
    if (!url) return;

    let cancelled = false;
    const controller = new AbortController();

    async function fetchData() {
      // Check cache first
      const cached = cache.current.get(url);
      if (cached && !options.skipCache) {
        setData(cached);
        setStatus("success");
        return;
      }

      setStatus("loading");
      setError(null);

      try {
        const res = await fetch(url, {
          ...options,
          signal: controller.signal,
        });

        if (!res.ok) {
          throw new Error(`HTTP ${res.status}`);
        }

        const json = await res.json();

        if (!cancelled) {
          cache.current.set(url, json);
          setData(json);
          setStatus("success");
        }
      } catch (err) {
        if (!cancelled && err.name !== "AbortError") {
          setError(err);
          setStatus("error");
        }
      }
    }

    fetchData();

    return () => {
      cancelled = true;
      controller.abort();
    };
  }, [url, options.skipCache]);

  return { data, error, status, isLoading: status === "loading" };
}

// Usage
function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useFetch(`/api/users/${userId}`);

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

Custom Hook: useLocalStorage

import { useState, useEffect } from "react";

function useLocalStorage(key, initialValue) {
  // Lazy initialization from localStorage
  const [value, setValue] = useState(() => {
    if (typeof window === "undefined") return initialValue; // SSR safety

    try {
      const stored = localStorage.getItem(key);
      return stored ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // Sync to localStorage
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch (err) {
      console.warn(`Failed to save ${key} to localStorage:`, err);
    }
  }, [key, value]);

  // Listen for changes in other tabs
  useEffect(() => {
    const handleStorage = (e) => {
      if (e.key === key && e.newValue) {
        setValue(JSON.parse(e.newValue));
      }
    };

    window.addEventListener("storage", handleStorage);
    return () => window.removeEventListener("storage", handleStorage);
  }, [key]);

  return [value, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage("theme", "light");
  const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);

  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
      <input
        type="range"
        min={12}
        max={24}
        value={fontSize}
        onChange={(e) => setFontSize(Number(e.target.value))}
      />
    </div>
  );
}

Custom Hook: useMediaQuery

import { useState, useEffect } from "react";

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => {
    if (typeof window === "undefined") return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const mediaQuery = window.matchMedia(query);
    const handler = (e) => setMatches(e.matches);

    // Modern browsers
    mediaQuery.addEventListener("change", handler);

    // Set initial value
    setMatches(mediaQuery.matches);

    return () => mediaQuery.removeEventListener("change", handler);
  }, [query]);

  return matches;
}

// Usage
function ResponsiveNav() {
  const isMobile = useMediaQuery("(max-width: 768px)");
  const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");

  return isMobile ? (
    <MobileNav dark={prefersDark} />
  ) : (
    <DesktopNav dark={prefersDark} />
  );
}

For animation hooks, Framer Motion provides useAnimate, useScroll, and useMotionValue that integrate with these patterns.

Testing Custom Hooks

Testing hooks ensures they work correctly across different scenarios. Use @testing-library/react with its renderHook utility for isolated hook testing.

Testing with renderHook

import { renderHook, act, waitFor } from "@testing-library/react";
import { useCounter } from "./useCounter";

// Simple hook to test
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  return { count, increment, decrement };
}

// Tests
describe("useCounter", () => {
  it("initializes with default value", () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it("initializes with custom value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it("increments count", () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it("decrements count", () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });
});

Testing Async Hooks

import { renderHook, waitFor } from "@testing-library/react";
import { useFetch } from "./useFetch";

// Mock fetch
global.fetch = jest.fn();

describe("useFetch", () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  it("fetches data successfully", async () => {
    const mockData = { id: 1, name: "Test User" };
    fetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockData),
    });

    const { result } = renderHook(() => useFetch("/api/user"));

    // Initially loading
    expect(result.current.status).toBe("loading");

    // Wait for fetch to complete
    await waitFor(() => {
      expect(result.current.status).toBe("success");
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBeNull();
  });

  it("handles fetch errors", async () => {
    fetch.mockRejectedValueOnce(new Error("Network error"));

    const { result } = renderHook(() => useFetch("/api/user"));

    await waitFor(() => {
      expect(result.current.status).toBe("error");
    });

    expect(result.current.error.message).toBe("Network error");
    expect(result.current.data).toBeNull();
  });
});

Testing Hooks with Context

import { renderHook } from "@testing-library/react";
import { useAuth, AuthProvider } from "./auth";

describe("useAuth", () => {
  it("throws error when used outside provider", () => {
    // Suppress console.error for this test
    const spy = jest.spyOn(console, "error").mockImplementation(() => {});

    expect(() => {
      renderHook(() => useAuth());
    }).toThrow("useAuth must be used within AuthProvider");

    spy.mockRestore();
  });

  it("returns auth context when inside provider", () => {
    const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>;

    const { result } = renderHook(() => useAuth(), { wrapper });

    expect(result.current).toHaveProperty("user");
    expect(result.current).toHaveProperty("login");
    expect(result.current).toHaveProperty("logout");
  });
});

Hook Testing Best Practices

PracticeWhy
Use act() for state updatesEnsures React processes updates before assertions
Use waitFor() for async operationsWaits for async state changes to complete
Test behavior, not implementationFocus on what the hook returns, not internal state
Provide wrapper for context-dependent hooksEnsures hooks have required providers
Mock external dependenciesIsolates hook logic from network/browser APIs

For more on testing interactive components, see Building Interactive UI Components.

Hooks in Next.js and Remix

Modern React frameworks have specific considerations for hooks due to server-side rendering (SSR) and their data fetching patterns.

Next.js App Router (React Server Components)

Next.js App Router - Server Components vs Client Components comparison

Key patterns for Next.js:

// app/page.js - Server Component (default)
// ❌ Cannot use hooks here
export default async function Page() {
  // ✅ Fetch directly - no useEffect needed
  const data = await fetch("https://api.example.com/data").then((r) =>
    r.json(),
  );

  return (
    <div>
      <h1>{data.title}</h1>
      <InteractiveWidget initialData={data} />
    </div>
  );
}

// app/InteractiveWidget.js - Client Component
("use client"); // Required for hooks

import { useState, useEffect } from "react";

export function InteractiveWidget({ initialData }) {
  const [data, setData] = useState(initialData);
  const [isEditing, setIsEditing] = useState(false);

  // useEffect only runs on client after hydration
  useEffect(() => {
    // Safe to access browser APIs here
    const saved = localStorage.getItem("widget-state");
    if (saved) setData(JSON.parse(saved));
  }, []);

  return (
    <div>
      <button onClick={() => setIsEditing(!isEditing)}>
        {isEditing ? "Save" : "Edit"}
      </button>
      {/* ... */}
    </div>
  );
}

Next.js hook gotchas:

IssueSolution
useLayoutEffect SSR warningUse useEffect or check typeof window !== 'undefined'
Hooks in Server ComponentsMove to Client Component with 'use client'
Hydration mismatchEnsure server/client render same initial content
Router hooks (useRouter)Only in Client Components

Remix

Remix handles data fetching via loaders, reducing the need for useEffect data fetching:

// routes/users.$userId.jsx
import { useLoaderData, useFetcher } from "@remix-run/react";
import { useState } from "react";

// Loader runs on server - no hooks needed
export async function loader({ params }) {
  const user = await db.user.findUnique({ where: { id: params.userId } });
  return json({ user });
}

// Component can use hooks for client-side interactivity
export default function UserProfile() {
  // useLoaderData replaces useEffect for initial data
  const { user } = useLoaderData();
  const [isEditing, setIsEditing] = useState(false);
  const fetcher = useFetcher();

  return (
    <div>
      <h1>{user.name}</h1>

      {/* Client-side state for UI */}
      <button onClick={() => setIsEditing(!isEditing)}>
        {isEditing ? "Cancel" : "Edit"}
      </button>

      {/* Remix fetcher for mutations */}
      <fetcher.Form method="post">
        <input name="name" defaultValue={user.name} />
        <button type="submit">Save</button>
      </fetcher.Form>
    </div>
  );
}

Remix hook patterns:

Remix HookReplacesPurpose
useLoaderData()useEffect fetchGet server-loaded data
useActionData()Form stateGet form submission result
useFetcher()useEffect + useStateClient-side data mutations
useNavigation()Loading stateTrack navigation/submission status

Framework-Agnostic Best Practices

  1. Minimize client-side data fetching - Use framework loaders (Next.js Server Components, Remix loaders) instead of useEffect
  2. Guard browser APIs - Always check typeof window !== 'undefined' in hooks that access browser APIs
  3. Provide SSR fallbacks - Use getServerSnapshot in useSyncExternalStore for SSR
  4. Mark client components explicitly - Use 'use client' in Next.js for any component with hooks
  5. Test hydration - Ensure server and client render identical initial content

For comprehensive coverage of Server Components architecture, see our React Server Components Practical Guide.

Common Mistakes and How to Avoid Them

MistakeSymptomSolution
Missing dependenciesStale values, bugsUse eslint-plugin-react-hooks
Omitting dependency arrayInfinite loopsAlways include [] or [deps]
Object/array literal in depsEffect runs every renderMemoize with useMemo or define outside
Calling Hooks conditionally”Rendered more hooks than previous render”Move conditions inside Hook logic
Overusing useMemo/useCallbackUnnecessary overheadOnly optimize measured bottlenecks
Mutating state directlyUI doesn’t updateAlways use setter with new reference
Forgetting cleanupMemory leaks, stale subscriptionsReturn cleanup function from useEffect

Why Does My useEffect Run Twice in Development?

React’s Strict Mode intentionally double-invokes effects to help you find bugs. In development, React mounts components, unmounts them, then mounts again to verify your cleanup logic works correctly. This only happens in development-production runs effects once.

// This runs twice in dev (Strict Mode), once in production
useEffect(() => {
  console.log("Effect ran");
  return () => console.log("Cleanup ran");
}, []);

Fix: Ensure your effect has proper cleanup. If the double-run causes issues, your cleanup function is likely incomplete. Use React DevTools Profiler to debug effect behavior and identify performance bottlenecks.

Object Dependencies Anti-Pattern

// ❌ Bug: new object every render → effect runs infinitely
useEffect(() => {
  fetchData(options);
}, [{ page: 1, limit: 10 }]); // New object reference each render!

// ✅ Fix 1: Primitive dependencies
useEffect(() => {
  fetchData({ page, limit });
}, [page, limit]);

// ✅ Fix 2: Memoize the object
const options = useMemo(() => ({ page, limit }), [page, limit]);
useEffect(() => {
  fetchData(options);
}, [options]);

// ✅ Fix 3: Define outside component (if static)
const OPTIONS = { page: 1, limit: 10 };
function Component() {
  useEffect(() => {
    fetchData(OPTIONS);
  }, []); // OPTIONS reference never changes
}

Migration Guide: Modernizing Legacy Hook Patterns

If you’re maintaining a codebase with heavy useEffect usage, here’s a systematic approach to modernize it for React 19.

Step 1: Audit Your useEffect Usage

Categorize each useEffect in your codebase:

CategoryActionPriority
Data fetchingReplace with use() + Suspense or TanStack QueryHigh
Derived stateRemove, compute during renderHigh
Subscriptions (WebSocket, events)Keep useEffectKeep
Third-party library syncKeep useEffectKeep
State reset on prop changeReplace with key propMedium

Step 2: Replace Data Fetching Patterns

// ❌ Legacy: useEffect for data fetching
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetchUser(userId)
      .then((data) => !cancelled && setUser(data))
      .catch((err) => !cancelled && setError(err))
      .finally(() => !cancelled && setLoading(false));

    return () => {
      cancelled = true;
    };
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <Error />;
  return <Profile user={user} />;
}

// ✅ Modern: use() + Suspense (React 19)
function UserProfile({ userPromise }) {
  const user = use(userPromise);
  return <Profile user={user} />;
}

// Parent handles loading/error via Suspense + ErrorBoundary
function App({ userId }) {
  return (
    <ErrorBoundary fallback={<Error />}>
      <Suspense fallback={<Spinner />}>
        <UserProfile userPromise={fetchUser(userId)} />
      </Suspense>
    </ErrorBoundary>
  );
}

// ✅ Alternative: TanStack Query (works with any React version)
function UserProfile({ userId }) {
  const {
    data: user,
    isLoading,
    error,
  } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });

  if (isLoading) return <Spinner />;
  if (error) return <Error />;
  return <Profile user={user} />;
}

Step 3: Eliminate Derived State

// ❌ Legacy: useEffect for derived state
function SearchResults({ items, query }) {
  const [filtered, setFiltered] = useState([]);

  useEffect(() => {
    setFiltered(items.filter((i) => i.name.includes(query)));
  }, [items, query]);

  return <List items={filtered} />;
}

// ✅ Modern: compute during render
function SearchResults({ items, query }) {
  const filtered = items.filter((i) => i.name.includes(query));
  return <List items={filtered} />;
}

// ✅ If expensive, use useMemo
function SearchResults({ items, query }) {
  const filtered = useMemo(
    () => items.filter((i) => i.name.includes(query)),
    [items, query],
  );
  return <List items={filtered} />;
}

Step 4: Adopt React 19 Form Patterns

// ❌ Legacy: useState + onChange + onSubmit
function ContactForm() {
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState("");
  const [submitting, setSubmitting] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setSubmitting(true);
    await submitForm({ email, message });
    setSubmitting(false);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <textarea value={message} onChange={(e) => setMessage(e.target.value)} />
      <button disabled={submitting}>
        {submitting ? "Sending..." : "Send"}
      </button>
    </form>
  );
}

// ✅ Modern: Server Actions + useFormStatus (React 19)
function ContactForm() {
  async function submitAction(formData) {
    "use server";
    await submitForm({
      email: formData.get("email"),
      message: formData.get("message"),
    });
  }

  return (
    <form action={submitAction}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <SubmitButton />
    </form>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Sending..." : "Send"}</button>;
}

Migration Checklist

  • Install eslint-plugin-react-hooks and fix all warnings
  • Identify all useEffect calls that fetch data
  • Replace data fetching with use() + Suspense or TanStack Query
  • Remove useEffect calls that compute derived state
  • Replace state-reset-on-prop-change patterns with key prop
  • Adopt useFormStatus and useOptimistic for forms
  • Consider React Compiler for automatic memoization
  • Keep useEffect only for external system synchronization

If you’re working with accessible components, our accessibility guide covers WCAG 2.2 patterns that complement these hook patterns.

React Hooks Interview Questions

Common questions asked in React interviews, with concise answers:

What are the Rules of Hooks?

React enforces two rules for hooks:

  1. Only call hooks at the top level (not in loops, conditions, or nested functions)
  2. Only call hooks from React functions (components or custom hooks)

What’s the difference between useMemo and useCallback?

useMemo memoizes a computed value; useCallback memoizes a function definition. Use useMemo for expensive calculations, useCallback for callbacks passed to memoized children.

When should I use useReducer instead of useState?

Use useReducer when state has multiple sub-values, complex update logic, or when the next state depends on the previous state. It centralizes state transitions and makes them easier to test.

How do you prevent infinite loops in useEffect?

Always include a dependency array. If the effect updates state that’s in the dependency array, use functional updates (setState(prev => ...)) or restructure to avoid the cycle.

What causes the stale closure problem?

The stale closure problem occurs when a callback captures an outdated value from a previous render. Fix by adding the value to dependencies, using functional updates, or storing in a ref.

Can hooks be used in Server Components?

No. Server Components run on the server without React’s runtime. Use 'use client' directive to mark components that need hooks.

What’s the difference between useEffect and useLayoutEffect?

useEffect runs asynchronously after paint; useLayoutEffect runs synchronously before paint. Use useLayoutEffect only for DOM measurements that must happen before the user sees the update.

How do you share stateful logic between components?

Create a custom hook-a function starting with “use” that calls other hooks. Custom hooks encapsulate and reuse stateful logic without changing component hierarchy.

What is useSyncExternalStore for?

useSyncExternalStore subscribes to external data sources (browser APIs, state libraries) in a way that’s safe with concurrent rendering. It prevents “tearing” where different parts of the UI show inconsistent data.

How do you test custom hooks?

Use renderHook from @testing-library/react. Wrap state updates in act(), use waitFor() for async operations, and provide wrapper components for context-dependent hooks.

React 19 Form Hooks in Depth: useActionState

useActionState manages form state with Server Actions. It replaces the common pattern of managing form state, errors, and submission status manually.

import { useActionState } from "react";

async function createUser(previousState, formData) {
  "use server";

  const name = formData.get("name");
  if (!name) {
    return { error: "Name is required", success: false };
  }

  await db.user.create({ data: { name } });
  return { error: null, success: true };
}

function CreateUserForm() {
  const [state, formAction, isPending] = useActionState(createUser, {
    error: null,
    success: false,
  });

  return (
    <form action={formAction}>
      <input name="name" />
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">User created!</p>}
      <button disabled={isPending}>
        {isPending ? "Creating..." : "Create User"}
      </button>
    </form>
  );
}

Key differences from manual form handling:

  • Progressive enhancement: Works without JavaScript (form submits to server)
  • Automatic state management: No manual useState for errors, loading, success
  • Server Actions integration: Runs server-side logic directly from the form
  • Type-safe: Full TypeScript support for form state shape
Manual ApproachuseActionState
3-5 useState callsSingle hook
Manual error handlingBuilt-in error state
Client-onlyProgressive enhancement
Custom loading logicisPending flag

Next Steps

Start by auditing your useEffect calls using the migration checklist above. Identify data fetching patterns that can move to use() + Suspense, and derived state that should be computed during render. Here’s where to go next:

Frequently Asked Questions

What are React Hooks and why should I use them?
React Hooks are special functions that let you use state and other React features in functional components. They eliminate the need for class components, reduce boilerplate code, and make it easier to share stateful logic between components through custom hooks.
What is the new use() hook in React 19?
The use() hook reads the value of a Promise or Context within a component. Unlike other hooks, use() can be called inside loops and conditionals. It integrates with Suspense for data fetching, replacing many useEffect patterns with cleaner, more declarative code.
Should I still use useEffect for data fetching in React 19?
For most data fetching, prefer the use() hook with Suspense, Server Components, or libraries like TanStack Query. useEffect is still valid for subscriptions, event listeners, and synchronizing with external systems, but it's no longer the recommended pattern for fetching data.
What is the difference between useState and useReducer?
useState is ideal for simple state with primitive values or independent state updates. useReducer is better for complex state objects with multiple sub-values, when the next state depends on the previous one, or when you need predictable state transitions through a reducer function.
When should I use useMemo vs useCallback?
Use useMemo to memoize expensive computed values that shouldn't be recalculated on every render. Use useCallback to memoize function references. However, with React Compiler (React Forget), manual memoization becomes unnecessary as the compiler handles it automatically.
What is React Compiler and how does it affect hooks?
React Compiler (formerly React Forget) automatically memoizes components and hooks at build time. When enabled, you no longer need to manually write useMemo, useCallback, or React.memo-the compiler optimizes your code automatically. It's currently in beta and powers instagram.com.
Can I call Hooks inside loops or conditions?
Traditional hooks like useState and useEffect must be called at the top level. However, the new use() hook in React 19 can be called inside loops and conditionals, making it more flexible for reading Promises and Context.
How do I share stateful logic between components?
Create a custom Hook by extracting the shared logic into a function that starts with 'use' (e.g., useWindowSize, useFetch). Custom Hooks can call other Hooks and return any values needed by the components that use them.
What is useFormStatus in React 19?
useFormStatus returns the pending status of the parent form during submission. Use it to disable submit buttons and show loading states. It must be called from a component that's a child of a form element.
What is the stale closure problem in React Hooks?
The stale closure problem occurs when a callback function captures an outdated value from a previous render. This happens when dependencies are missing from useEffect or useCallback dependency arrays, causing the function to reference old state values.
Do React Hooks work with Server Components?
No, Hooks like useState and useEffect only work in Client Components. Server Components cannot use Hooks because they run on the server without React's runtime. Add 'use client' directive to use Hooks in the App Router.
How do I test React Hooks?
Use @testing-library/react-hooks for testing custom hooks in isolation, or test hooks through component tests with React Testing Library. For hooks with async behavior, use waitFor() to handle state updates. Avoid testing implementation details-test the behavior your hook exposes.
Why is my useEffect running twice in development?
React's Strict Mode intentionally double-invokes effects to help you find bugs. In development, React mounts components, unmounts them, then mounts again to verify your cleanup logic works correctly. This only happens in development-production runs effects once. If the double-run causes issues, your cleanup function is likely incomplete.
What is useSyncExternalStore and when should I use it?
useSyncExternalStore subscribes to external data sources (browser APIs, state libraries, custom stores) in a way that's safe with concurrent rendering. It prevents 'tearing' where different UI parts show inconsistent data. Most developers use it indirectly through libraries like Redux or Zustand.
How do I handle race conditions in useEffect?
Use a cancelled flag or AbortController to ignore stale responses. Set cancelled = true in the cleanup function, then check if (!cancelled) before updating state. For data fetching, prefer use() + Suspense or TanStack Query which handle race conditions automatically.