React Hooks and the Rules of Hooks

Hooks are plain JavaScript functions, prefixed with use, that let function components “hook into” React features like state, side effects, and context without writing a class. Before Hooks (React 16.8), state and lifecycle methods only existed on class components; Hooks let you get the same power inside simple functions, and they let you extract and reuse stateful logic between components. Every other lesson in this course leans on Hooks, so understanding how they work — and the rules that make them work reliably — is essential.

Overview: What Hooks Are and Why They Exist

A Hook is just a function that starts with the word use and can call other Hooks or interact with React’s internal state. The built-in ones you’ll use constantly are useState (local state), useEffect (side effects), useRef (mutable values and DOM access), useContext (reading context), useReducer (complex state transitions), and the performance Hooks useMemo and useCallback. You can also write your own custom Hooks that combine built-in Hooks into reusable logic.

Hooks solved three real problems with class components: logic that touched the same concern (like a subscription) had to be split across componentDidMount, componentDidUpdate, and componentWillUnmount; reusing stateful logic between components required awkward patterns like render props or higher-order components that added extra wrapper components to the tree; and this binding in classes was a constant source of confusion. Hooks let you colocate related logic in one function and share it by simply calling a custom Hook.

Under the hood, React does not use variable names to know which state belongs to which useState call — it uses call order. Each component instance has a linked list of “hook state” attached to it in React’s internal fiber tree. On the first render, every call to useState, useEffect, etc. appends a new entry to that list, in order. On every re-render, React walks the same list from the top and matches the Nth Hook call in your function to the Nth entry it stored last time. This is precisely why Hooks must be called in the exact same order on every render — if a hook call is skipped or an extra one appears (for example because it sits inside an if statement), React reads the wrong slot’s state for every hook that follows, corrupting your component silently or throwing an order-mismatch error.

This is also why Hooks may only be called from React function components or from other custom Hooks — never from regular JavaScript functions, event handlers, loops, or conditionals. React only tracks this ordered list while it is actively rendering a specific component; calling a Hook outside that context has no list to attach to.

The Rules of Hooks

Rule What it means
Only call Hooks at the top level Never inside loops, conditions (if/else), or nested functions. This guarantees the same Hooks run in the same order on every render.
Only call Hooks from React functions Call them from function components or from custom Hooks (functions whose name starts with use). Do not call them from plain helper functions or class components.
Custom Hooks must start with use This naming convention lets React’s linter (eslint-plugin-react-hooks) and other developers recognize that a function follows the Rules of Hooks.
Hooks run in the same order every render A consequence of the first rule — the order is what lets React match state to the correct useState/useEffect call across renders.

Almost everyone violates rule one accidentally by putting a Hook inside an early return or an if block. The fix is always the same: keep the Hook call unconditional at the top, and put the conditional logic inside the Hook’s callback or in the JSX you return.

Syntax

import { useState, useEffect } from "react";

function ComponentName() {
  const [state, setState] = useState(initialValue);

  useEffect(() => {
    // side effect code
    return () => {
      // optional cleanup
    };
  }, [dependencies]);

  return <div>{state}</div>;
}
  • Import — Hooks must be imported explicitly by name from "react"; there is no default “Hooks” object.
  • useState(initialValue) — returns a [value, setter] pair. Calling the setter schedules a re-render with the new value.
  • useEffect(callback, dependencies) — runs callback after React commits changes to the DOM. The dependencies array controls when it re-runs.
  • Cleanup function — the function optionally returned from the effect callback; React calls it before the effect runs again and when the component unmounts.
  • Top level only — no Hook call may live inside a condition, loop, or nested helper function.

Examples

Example 1: useState for local state

import { useState } from "react";

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

  function handleIncrement() {
    setCount((prevCount) => prevCount + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
}

export default Counter;

Renders: a paragraph reading “Count: 0” and a button labeled “Increment”. Each click calls setCount with an updater function, React schedules a re-render, and the displayed count goes up by one. Using the updater form (prevCount => prevCount + 1) instead of setCount(count + 1) avoids relying on a possibly-stale count captured in the closure — important if multiple updates happen before a re-render, such as inside a batched event.

Example 2: useEffect for a side effect with cleanup

import { useState, useEffect } from "react";

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

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

    async function fetchUsers() {
      const response = await fetch("https://api.example.com/users");
      const data = await response.json();
      if (!ignore) {
        setUsers(data);
        setLoading(false);
      }
    }

    fetchUsers();

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

  if (loading) {
    return <p>Loading users...</p>;
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

export default UserList;

Renders: “Loading users…” first, then a bulleted list of names such as Alice, Bob, Carol once the fetch resolves. The empty dependency array [] means the effect runs once after the first commit, mimicking componentDidMount. The ignore flag set in the cleanup function prevents a “stale” response from calling setState after the component has unmounted or the effect has re-run — a real-world race-condition guard you should use for every effect that fetches data.

Example 3: A custom Hook

import { useState, useEffect } from "react";

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage("theme", "light");

  function handleToggle() {
    setTheme(theme === "light" ? "dark" : "light");
  }

  return <button onClick={handleToggle}>Current theme: {theme}</button>;
}

export default ThemeToggle;

Renders: a button reading “Current theme: light” (or whatever was last saved). Clicking it toggles the label between “light” and “dark”, and the value persists across page reloads because the effect writes it to localStorage every time value changes. useLocalStorage is a custom Hook: it starts with use, it calls other Hooks (useState, useEffect) at its own top level, and any component can call it to get the exact same persisted-state behavior — this is the reuse problem Hooks were built to solve.

How It Works Under the Hood: Mount, Update, Unmount

On mount, React renders your component function top to bottom. Each Hook call creates a new entry in that component’s internal hook list: useState stores its initial value, useEffect queues its callback to run after the DOM is committed (not during render — this keeps rendering fast and pure). React then commits the resulting elements to the real DOM and, immediately after, runs any queued effects in the order they were declared.

On a state update, calling a setter (like setCount) tells React that this component’s state has changed. React schedules a re-render, calls your function component again, and walks the same hook list in the same order, handing back the current value for each useState call. For each useEffect, React compares the new dependency array to the previous one, item by item, using Object.is; if any dependency changed, it runs the previous effect’s cleanup function, then the new effect callback. If nothing changed, the effect is skipped entirely for that render.

On unmount, React removes the component from the tree and, for every effect that has a cleanup function, calls it one final time — this is where you cancel subscriptions, clear timers, or abort in-flight requests so they don’t try to update a component that no longer exists.

Common Mistakes

Mistake 1: Calling a Hook conditionally

import { useState, useEffect } from "react";

function Profile({ userId }) {
  if (userId) {
    // Wrong: hook called conditionally, breaking call order
    useEffect(() => {
      console.log("Fetching profile for", userId);
    }, [userId]);
  }

  const [name, setName] = useState("");

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

This is broken because whether useEffect runs at all now depends on userId. If userId is truthy on one render and falsy on the next, the number of Hooks called changes between renders, so React matches the wrong stored state to useState and either corrupts state or throws “React has detected a change in the order of Hooks called by Profile”. The condition needs to move inside the Hook, not around it:

import { useState, useEffect } from "react";

function Profile({ userId }) {
  const [name, setName] = useState("");

  useEffect(() => {
    if (userId) {
      console.log("Fetching profile for", userId);
    }
  }, [userId]);

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

Mistake 2: Missing dependencies causing a stale closure

import { useState, useEffect } from "react";

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(id);
  }, []);

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

The effect runs once (empty dependency array) and creates a closure over count as it was on that first render: 0. Every tick of the interval calls setCount(0 + 1), so the displayed value jumps to 1 and then gets stuck — it never goes higher, even though setInterval is still firing every second. Either add count to the dependency array (which would recreate the interval every second, defeating the point) or, better, use the functional updater form so the effect never needs to read count from its closure at all:

import { useState, useEffect } from "react";

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount((prevCount) => prevCount + 1);
    }, 1000);

    return () => clearInterval(id);
  }, []);

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

Other frequent mistakes worth knowing: mutating state directly (state.push(x) instead of setState([...state, x])) does not trigger a re-render because React compares references, not deep contents; and forgetting a key prop on list items causes React’s reconciler to misidentify which DOM node corresponds to which array item across re-renders, leading to visually swapped or duplicated content.

Best Practices

  • Install and follow eslint-plugin-react-hooks — its exhaustive-deps rule catches missing dependencies and conditional Hook calls automatically.
  • Always pass a dependency array to useEffect; an effect with no array at all runs after every single render, which is rarely what you want.
  • Prefer the functional updater form (setCount(c => c + 1)) whenever a new state value depends on the previous one.
  • Extract repeated stateful logic into a custom Hook named useSomething instead of copy-pasting useState/useEffect blocks across components.
  • Keep effects focused on one concern each — multiple small useEffect calls are easier to reason about than one large effect handling several unrelated side effects.
  • Always return a cleanup function from effects that subscribe, set timers, or open connections.
  • Never call Hooks inside loops, conditions, or nested functions — restructure the condition to live inside the Hook body instead.

Practice Exercises

  • Build a useToggle(initialValue) custom Hook that returns [value, toggle], where calling toggle flips a boolean. Use it to show/hide a paragraph of text with a button.
  • Take the Timer component from Mistake 2 and add a “Pause” button that stops the interval using clearInterval in the cleanup function, without deleting the accumulated count. Hint: control whether the effect subscribes based on an isRunning state value in the dependency array.
  • Write a useWindowWidth() custom Hook that tracks window.innerWidth using a resize event listener added in useEffect, with proper cleanup via removeEventListener. Use it in a component that renders “Mobile” or “Desktop” depending on the width.

Summary

  • Hooks are functions starting with use that let function components access state, effects, refs, and context.
  • React matches Hooks between renders by call order, not by name — this is why the Rules of Hooks exist.
  • Only call Hooks at the top level of a component or custom Hook, never inside conditions, loops, or nested functions.
  • useEffect runs after the DOM commits; its dependency array controls when it re-runs, and its return value is a cleanup function called before the next run and on unmount.
  • Custom Hooks are the standard way to extract and reuse stateful logic between components.
  • Missing dependencies cause stale closures; the functional updater form of a state setter sidesteps most of these bugs.