AnyLearn
All lessons
Programmingintermediate

React Hooks: State and Effects

useState, useEffect, useReducer, and useContext in React 19: the rules, snapshot semantics, dependency arrays, when NOT to useEffect, and the dependency-array footgun that bites everyone.

Not signed in β€” your progress and quiz score won't be saved.
Lesson progress1 / 10

The two rules of hooks

Hooks let function components hold state and interact with the React runtime. Two rules make them work:

  1. Only call hooks at the top level. Not inside loops, conditions, or nested functions. React identifies each hook by its call order within a render; conditional calls break that order and you'll see Rendered fewer hooks than expected errors.
  2. Only call hooks from React functions. Components, or other custom hooks. Not from regular utility functions, not from event handlers' inner async callbacks.

The React 19 compiler (still optional in 2026) loosens the second rule for some patterns, but the first one is hard. Lint with eslint-plugin-react-hooks and the rules-of-hooks rule on β€” it catches almost every violation.

Full lesson text

All 10 steps on one page β€” for reading, reference, and search.

Show

1. The two rules of hooks

Hooks let function components hold state and interact with the React runtime. Two rules make them work:

  1. Only call hooks at the top level. Not inside loops, conditions, or nested functions. React identifies each hook by its call order within a render; conditional calls break that order and you'll see Rendered fewer hooks than expected errors.
  2. Only call hooks from React functions. Components, or other custom hooks. Not from regular utility functions, not from event handlers' inner async callbacks.

The React 19 compiler (still optional in 2026) loosens the second rule for some patterns, but the first one is hard. Lint with eslint-plugin-react-hooks and the rules-of-hooks rule on β€” it catches almost every violation.

2. useState: snapshot semantics

useState gives you a value and a setter. The value is a snapshot of this render, not a live reference.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => {
      setCount(count + 1);
      setCount(count + 1);   // still 1, not 2!
    }}>{count}</button>
  );
}

Both setCount(count + 1) calls see count = 0 because the closure captured that render's value. To stack updates, pass the updater form: setCount(c => c + 1). Now React threads the previous state through and you actually get +2.

3. Batched updates

React 19 batches all setState calls that happen during the same event, effect, or microtask into one re-render. So this:

function onClick() {
  setA(1);
  setB(2);
  setC(3);
}

results in one re-render, not three. Batching used to apply only inside React event handlers; in React 18+ it covers everything β€” setTimeout, async fetches, Promises. If you ever genuinely need to opt out (rare), flushSync from react-dom forces a synchronous flush. You almost never want it; reach for it only when something measurement-related (like focusing the new element after layout) absolutely needs the DOM to settle before the next line runs.

4. useEffect: when, and when NOT

useEffect(fn, deps) runs fn after React commits the DOM and any time the values in deps change. Use it for synchronizing with something outside React β€” subscriptions, manual DOM, document title, analytics.

useEffect(() => {
  document.title = `Inbox (${unread})`;
}, [unread]);

Do NOT use useEffect to compute derived state from props. Just compute it during render:

// ❌ pointless effect + double render
const [full, setFull] = useState('');
useEffect(() => setFull(`${first} ${last}`), [first, last]);

// βœ… derive during render
const full = `${first} ${last}`;

The React docs literally have a section called "You might not need an effect." Read it. It is the most common React mistake.

5. The dependency-array footgun

Every value from the render scope that the effect uses must be in deps. Forget one and you'll capture a stale value forever.

function Search({ query }: { query: string }) {
  useEffect(() => {
    fetch(`/api/search?q=${query}`);  // uses query
  }, []);                              // ❌ but [] says 'never re-run'
}

The effect captures the first render's query and ignores every later change. Fix: include [query]. The react-hooks/exhaustive-deps ESLint rule catches this β€” keep it on, fix the deps it suggests, don't disable it. If a dependency makes the effect re-run too often, the bug is usually that the dependency itself is being recreated each render (a fresh object/function literal); fix that with useMemo/useCallback or by moving the value out of the component.

6. Cleanup: the second return

If your effect subscribes to anything, return a cleanup function. React runs it before the next effect AND when the component unmounts.

useEffect(() => {
  const ctrl = new AbortController();
  fetch(`/api/users/${id}`, { signal: ctrl.signal })
    .then(r => r.json())
    .then(setUser);
  return () => ctrl.abort();   // cancel if id changes or we unmount
}, [id]);

Without cleanup you get classic bugs: stale subscriptions firing into unmounted trees, memory leaks, two responses racing to set state where the slower (older) one wins and shows wrong data.

7. Component lifecycle with effects

Each render commits the DOM, then runs effects whose deps changed. Cleanup of the previous run fires first.

flowchart TD
  A["render (compute JSX)"] --> B["commit DOM"]
  B --> C["run cleanup of previous effects"]
  C --> D["run new effects whose deps changed"]
  D --> E["browser paints"]
  E --> F["state change or props change"]
  F --> A

8. useReducer for complex state

When state has more than 2-3 fields or transitions are non-trivial, useReducer keeps logic out of your handlers.

type Action =
  | { type: 'add'; title: string }
  | { type: 'toggle'; id: string }
  | { type: 'remove'; id: string };

function reducer(state: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case 'add':    return [...state, { id: crypto.randomUUID(), title: action.title, done: false }];
    case 'toggle': return state.map(t => t.id === action.id ? { ...t, done: !t.done } : t);
    case 'remove': return state.filter(t => t.id !== action.id);
  }
}

function App() {
  const [todos, dispatch] = useReducer(reducer, []);
  return <button onClick={() => dispatch({ type: 'add', title: 'walk dog' })}>+</button>;
}

It's useState + Redux without the Redux. Tests get easier because the reducer is a pure function.

9. useContext for escaping prop drilling

Prop drilling is when you thread a value through 5 components that don't care about it just to reach the one that does. useContext short-circuits that.

const ThemeCtx = createContext<'light' | 'dark'>('light');

function App() {
  return (
    <ThemeCtx.Provider value="dark">
      <Page />
    </ThemeCtx.Provider>
  );
}

function Button() {
  const theme = useContext(ThemeCtx);   // any depth, any descendant
  return <button className={theme}>OK</button>;
}

Gotchas: every consumer re-renders when the provider's value changes by reference. Don't pass a fresh {} each render β€” memoize it. And don't use context as a global store for everything; for cross-cutting client state (selection, theme, locale) it's perfect; for server data, use a data-fetching library or RSC instead.

10. Lifting state up

When two sibling components need to share state, move it to their nearest common parent and pass it down. This is the same principle that makes useReducer tidy and props one-way.

function Filters() {
  const [query, setQuery] = useState('');
  return (
    <>
      <SearchBox value={query} onChange={setQuery} />
      <Results query={query} />
    </>
  );
}

If 'the nearest common parent' is way up the tree, you have two reasonable choices: lift it anyway and accept the prop chain (often fine), or put it in context (when many descendants need it). What you should NOT do is duplicate the state in both siblings and sync them via effects β€” that's the bug factory we warned about.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Why does `setCount(count + 1); setCount(count + 1);` only increment by 1?
    • Both calls capture the same render's snapshot of count, so both compute 0 + 1.
    • React de-duplicates identical setter calls within an event.
    • useState ignores the second call until the first commits.
    • count is hoisted, so the second read sees the new value but the setter cancels.
  2. An effect with `useEffect(() => fetch(`/api?q=${q}`), [])` runs once and never refetches when q changes. Best fix?
    • Add q to the dependency array: [q].
    • Remove the dependency array entirely so it always re-runs.
    • Wrap fetch in useCallback and drop deps.
    • Move the fetch call out of useEffect into the component body.
  3. Which is the BEST use of useEffect?
    • Synchronizing with something outside React, like a WebSocket subscription, with cleanup on unmount.
    • Computing a derived `fullName` from `first` and `last` props.
    • Reading two pieces of state and combining them into a memoized value.
    • Replacing useState because you 'feel like' the value should be reactive.
  4. useReducer is most clearly the right call when…
    • State has multiple fields and several discrete transition kinds you want centralized.
    • You need a value to be readable from any descendant without props.
    • You want to avoid re-rendering a heavy subtree on each parent state change.
    • You're trying to fetch data on mount.
  5. Every descendant of a Theme.Provider re-renders on each parent render. The provider's value is `{ mode: 'dark' }`. Why, and how do you fix it?
    • A new object literal is created each render, so context consumers see a new reference; memoize the value with useMemo.
    • Context always re-renders all consumers; switch to useState.
    • React 19 disabled context memoization; downgrade to 18.
    • Theme.Provider doesn't accept inline objects; use a string.

Related lessons