Field Guide · Vol. 8
Render → State → Effects

React Hooks, From The Inside Out.

Hooks look like magic — a function that "remembers" a value across renders, another that fires only when dependencies change. But there is no magic. There is an array, an index, and a render counter. Once you've seen the trick, you'll never confuse a hook rule again.

What this guide covers

  1. What a hook actually is
  2. Why effects are called "side effects"
  3. How useState works internally (with our own implementation)
  4. How useEffect works internally
  5. Why hooks can't be conditional — the array trick
  6. Async functions in useEffect — the right pattern
  7. Taming the async-state-update trap (stale closures, cleanup)
  8. Building a custom hook in 3 steps
  9. Custom implementations of all 9 hooks from the cheat sheet
01 · The Mental Model

A hook is a function with memory

In a normal function, local variables vanish when the function returns. A React component is just a function — it runs top to bottom on every render, then dies. So how does useState remember last render's count? It doesn't. React does, on its behalf.

Every hook is a function that closes over React's internal storage. When you call useState(0):

React looks up "the next slot" for this component.
A pointer it maintains across renders — you'll see how this works in step 03.
If the slot is empty (first render), React stores your initial value and returns it.
Otherwise React returns whatever was stored last time — the persisted state.
React hands back a setter that, when called, updates the slot and schedules a re-render.
The setter is stable: same function reference across all renders.
The unified definition: a hook is a function that calls into React's internal renderer to read or write a slot of memory tied to the current component instance. useState, useEffect, useRef, and your custom hooks are all variations on this one idea.
02 · Etymology Of "Side Effect"

Why we call them effects

In functional programming, a "pure" function returns a value and does nothing else: no logging, no DOM mutation, no network calls, no setting timers. Same input → same output, always. Anything a function does besides returning a value is a side effect.

Pure

function add(a, b) {
  return a + b;
}

No outside world touched. Idempotent. Testable in isolation.

Impure (has side effects)

function addAndLog(a, b) {
  console.log(a, b);  // side effect
  fetch('/track');     // side effect
  return a + b;
}

Same inputs, but visible footprint outside the function.

React's render function is supposed to be pure: state + props → JSX, with no side effects. But real apps have to talk to the world — fetch data, subscribe to events, manipulate the DOM, set timers. useEffect is React's designated escape hatch: a sanctioned place to put the impurity, deferred until after render, where it can't corrupt the rendering process itself.

So "side effect" isn't React jargon. React adopted a term from functional programming to name exactly what it means: anything besides computing JSX.

03 · useState, Demystified

How React remembers between renders

Here's the simplest possible mental model: React keeps a list of hook slots per component, and a cursor that walks the list during each render.

COMPONENT INSTANCE · INTERNAL STORAGE slot 0 count: 5 slot 1 name: 'Goldi' slot 2 — effect deps: [5] slot 3 — ref { current: ... } RENDER CURSOR · WALKS THE LIST IN ORDER i = 0 useState i = 1 useState i = 2 useEffect i = 3 useRef

Fig. 1 — Each hook call advances the cursor. The order of calls is the only thing that maps your code to React's storage.

Our own useState — 20 lines

// React's internal state — simplified
let hookStates = [];     // the slots
let hookIndex = 0;       // the cursor
let currentComponent;     // what's rendering right now

function useState(initialValue) {
  const i = hookIndex;     // snapshot — for closure

  if (hookStates[i] === undefined) {
    hookStates[i] = initialValue;          // first render
  }

  const setState = (newValue) => {
    hookStates[i] = typeof newValue === 'function'
      ? newValue(hookStates[i])
      : newValue;
    rerender();           // schedule a re-render
  };

  hookIndex++;            // advance cursor for next hook
  return [hookStates[i], setState];
}

function rerender() {
  hookIndex = 0;          // reset cursor — critical!
  currentComponent();   // run the component again
}

That's it. The cursor resets on every render and walks the same list in the same order. State persists because the slots live outside the component function.

What this teaches you about setState being async: calling setState doesn't change the variable you already destructured — that's a value frozen at render time. It updates the slot. You only see the new value on the NEXT render, when the slot is re-read.

04 · useEffect, Demystified

Side effects, deferred and tracked

useEffect stores two things in its slot: the cleanup function from last run, and the dependency array from last run. On every render, it checks if deps changed; if so, it runs cleanup, then runs the new effect.

function useEffect(callback, deps) {
  const i = hookIndex;
  const prev = hookStates[i];     // { deps, cleanup } or undefined

  const hasChanged = !prev
    || !deps                                   // no deps array = run every time
    || deps.some((d, idx) => d !== prev.deps[idx]);

  if (hasChanged) {
    // Run cleanup from previous effect, if any
    if (prev && typeof prev.cleanup === 'function') {
      prev.cleanup();
    }

    // Defer the new effect until after paint
    queueMicrotask(() => {
      const cleanup = callback();
      hookStates[i] = { deps, cleanup };
    });
  }

  hookIndex++;
}

The three dep-array flavors

No deps array
Runs after EVERY render. Usually a bug. useEffect(fn)
[ ]
Empty deps array
Runs once after mount, cleanup on unmount. useEffect(fn, [])
[x, y]
Specific deps
Runs when any dep changes by Object.is comparison.
The real difference between useEffect and useLayoutEffect: useEffect runs after the browser paints. useLayoutEffect runs synchronously after DOM mutations but before paint. Same internal mechanism — different scheduling.
05 · The Rules

Why hooks can't be conditional

Now you can answer this yourself. The "rules of hooks" exist because of the array-index trick. Look at what happens if you sneak a hook inside an if.

✗ Conditional hook
function Profile({ loggedIn }) {
  const [name, setName] = useState('guest');    // slot 0

  if (loggedIn) {
    const [email, setEmail] = useState('');    // slot 1 (sometimes)
  }

  const [theme, setTheme] = useState('dark');   // slot ??
  // When loggedIn=true:  theme is slot 2
  // When loggedIn=false: theme is slot 1 — and reads the WRONG state
}
RENDER 1 · loggedIn = true slot 0 — name 'guest' slot 1 — email '' slot 2 — theme 'dark' RENDER 2 · loggedIn = false · CURSOR SKIPS THE IF slot 0 — name ✓ 'guest' (correct) slot 1 — theme reads from EMAIL slot! '' (wrong type, wrong value) slot 2 — orphaned → React shouts "Rules of Hooks" violation and refuses to render.

Fig. 2 — When the cursor count drifts between renders, hook state corrupts. React detects this and throws.

The actual rules, restated

  1. Only call hooks at the top level. Not inside loops, conditions, or nested functions — anything that could change call order between renders.
  2. Only call hooks from React functions. Components or custom hooks (which are themselves React functions).
  3. The eslint-plugin-react-hooks linter enforces both rules statically.

The escape hatch for "conditional logic": put the condition INSIDE the hook, not around it. useEffect(() => { if (loggedIn) { ... } }, [loggedIn]) is fine. if (loggedIn) useEffect(...) is not.

06 · Async in useEffect

You can't mark useEffect itself async

A common first attempt that React rejects:

✗ The wrong way
useEffect(async () => {
  const data = await fetch('/api');
  setData(data);
}, []);
// React expects useEffect to return either nothing or a cleanup function.
// An async function returns a Promise. React tries to call it as cleanup → crash.
✓ Wrap an async function inside
useEffect(() => {
  async function run() {
    const data = await fetch('/api');
    setData(data);
  }
  run();
}, []);
✓ Or use an IIFE
useEffect(() => {
  (async () => {
    const data = await fetch('/api');
    setData(data);
  })();
}, []);

Why this rule exists

The return value of useEffect's callback is the cleanup function. React calls it before the next effect runs and on unmount. If your callback is async, it implicitly returns a Promise — and React would try to invoke that Promise as a cleanup function, which throws.

07 · The Async Update Trap

Nullifying async in edge cases

Async state updates create three failure modes that bite real apps. Each has a specific cure.

Problem 1: setState after unmount

You fire a fetch in useEffect. The user navigates away before it lands. The fetch resolves, calls setData — on a component that no longer exists. Memory leak and warning.

✗ Unguarded
useEffect(() => {
  fetch('/api').then(r => r.json()).then(setData);
}, []);
// If component unmounts before fetch resolves: warning, leak
✓ Cancellation flag (the canonical fix)
useEffect(() => {
  let cancelled = false;

  fetch('/api')
    .then(r => r.json())
    .then(data => {
      if (!cancelled) setData(data);
    });

  return () => { cancelled = true; };
}, []);
✓ Even better — AbortController
useEffect(() => {
  const controller = new AbortController();

  fetch('/api', { signal: controller.signal })
    .then(r => r.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });

  return () => controller.abort();   // stops the request itself
}, []);

Problem 2: stale closure on state

You read state inside a setTimeout or async callback. By the time it runs, state has changed — but your callback has the OLD value baked in.

✗ Stale
const [count, setCount] = useState(0);

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);    // count is FROZEN at the closure's snapshot
  }, 1000);
  return () => clearInterval(id);
}, []);   // no deps = effect runs once, closure captures count=0 forever
✓ Functional updater — reads current state at call time
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);    // React passes the current value
  }, 1000);
  return () => clearInterval(id);
}, []);

The functional setter — setCount(c => c + 1) — bypasses the closure entirely. React reads from the slot at the moment the setter runs.

Problem 3: race conditions in fetch

User types fast. Each keystroke fires a search. Responses come back out of order. You render the OLD response after the NEW one because it arrived later.

✓ Last-write-wins via cancellation
useEffect(() => {
  let active = true;

  fetch('/search?q=' + query)
    .then(r => r.json())
    .then(results => {
      if (active) setResults(results);    // only the latest effect's "active" is true
    });

  return () => { active = false; };       // previous effects mark themselves stale
}, [query]);

Each render creates a new active flag. When deps change, the previous effect's cleanup flips its flag to false. So only the latest in-flight request can update state.

The pattern, generalized: any async work in an effect needs a way to cancel or no-op when its closure is stale. Cleanup function + local flag + check before setState is the bulletproof three-step.

08 · Custom Hooks

A custom hook is just a function

No registration, no API. Three simple rules and you're done.

Name it starting with use.
That's how the linter knows to enforce hook rules on it. Not a runtime requirement — a convention.
Call other hooks inside it.
If you don't, it's just a regular function. Custom hooks compose built-in hooks.
Return whatever's useful: state, setters, refs, helpers.
Common shapes: a single value, a tuple [value, setter], or an object { data, loading, error }.

Example: useToggle in 4 lines

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  return [value, toggle];
}

Example: useDebouncedValue

function useDebouncedValue(value, delay) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);   // cancel pending update on change
  }, [value, delay]);

  return debounced;
}

Notice the cleanup pattern: every render that changes value cancels the previous pending update. Only the final value sticks. This is the search-box debounce in 7 lines.

09 · The Cheat Sheet, Re-Built

All 9 hooks from scratch

Every built-in hook is built on the same array-of-slots primitive. Below: minimal implementations of each. (Not production-grade — but accurate enough to teach the model.)

// Shared infrastructure — same as step 03
let hookStates = [];
let hookIndex = 0;
let currentComponent;

function render(Component) {
  currentComponent = Component;
  hookIndex = 0;
  return Component();
}

1. useState

function useState(initial) {
  const i = hookIndex++;
  if (hookStates[i] === undefined) {
    hookStates[i] = typeof initial === 'function' ? initial() : initial;
  }
  const setState = (next) => {
    hookStates[i] = typeof next === 'function'
      ? next(hookStates[i])
      : next;
    render(currentComponent);
  };
  return [hookStates[i], setState];
}

2. useEffect

function useEffect(callback, deps) {
  const i = hookIndex++;
  const prev = hookStates[i];
  const changed = !prev || !deps
    || deps.some((d, k) => !Object.is(d, prev.deps[k]));

  if (changed) {
    if (prev?.cleanup) prev.cleanup();
    queueMicrotask(() => {
      const cleanup = callback();
      hookStates[i] = { deps, cleanup };
    });
  }
}

3. useMemo

function useMemo(factory, deps) {
  const i = hookIndex++;
  const prev = hookStates[i];
  const changed = !prev
    || deps.some((d, k) => !Object.is(d, prev.deps[k]));

  if (changed) {
    hookStates[i] = { deps, value: factory() };
  }
  return hookStates[i].value;
}

4. useCallback

// useCallback IS useMemo of a function. Literally.
function useCallback(fn, deps) {
  return useMemo(() => fn, deps);
}

5. useRef

function useRef(initial) {
  const i = hookIndex++;
  if (hookStates[i] === undefined) {
    hookStates[i] = { current: initial };
  }
  return hookStates[i];        // SAME object across renders — that's why mutation doesn't re-render
}

6. useReducer

function useReducer(reducer, initial) {
  const [state, setState] = useState(initial);
  const dispatch = (action) => setState(s => reducer(s, action));
  return [state, dispatch];
}

Yes — useReducer is just useState plus a function that calls the reducer. The "complex state pattern" is one line of glue.

7. useContext

// Context object stores its current value globally per provider
function createContext(defaultValue) {
  return { _value: defaultValue, _subscribers: new Set() };
}

function useContext(context) {
  const i = hookIndex++;
  if (hookStates[i] === undefined) {
    context._subscribers.add(currentComponent);    // re-render on change
    hookStates[i] = true;
  }
  return context._value;
}

// Provider sets the value and re-renders subscribers
function Provider(context, value) {
  context._value = value;
  context._subscribers.forEach(c => render(c));
}

8. useLayoutEffect

function useLayoutEffect(callback, deps) {
  const i = hookIndex++;
  const prev = hookStates[i];
  const changed = !prev || !deps
    || deps.some((d, k) => !Object.is(d, prev.deps[k]));

  if (changed) {
    if (prev?.cleanup) prev.cleanup();
    const cleanup = callback();       // SYNC — no queueMicrotask
    hookStates[i] = { deps, cleanup };
  }
}

The only difference from useEffect: the callback runs synchronously after DOM mutation, before paint. Use sparingly — it blocks the next frame.

9. useId

let idCounter = 0;

function useId() {
  const i = hookIndex++;
  if (hookStates[i] === undefined) {
    hookStates[i] = ':r' + (idCounter++) + ':';
  }
  return hookStates[i];
}

Real useId is more complex (stable across server/client hydration via tree position), but the principle is the same: store a counter in a slot so the value survives across renders.

What you can confidently say in an interview

"Hooks are functions that read and write a per-component array of slots maintained by React. Each call advances a cursor. This is why hooks can't be conditional: the cursor positions must match across renders, or state corrupts. useState stores a value plus a setter that updates the slot and re-renders. useEffect stores a dep array plus a cleanup function, runs after paint, and re-runs only when deps change. All other built-in hooks — useMemo, useCallback, useRef, useReducer, useContext, useLayoutEffect, useId — are variations on the same primitive."

Eight facts that fall out

  1. "Side effect" is FP vocabulary — anything besides returning a value. React adopted the term verbatim.
  2. Hooks are an array indexed by call order. That's the whole trick.
  3. Rules of hooks exist to keep the index stable across renders. Linter enforces statically.
  4. useEffect can't be async — the return slot is reserved for cleanup, not a Promise.
  5. Stale closures happen when async callbacks capture old state. Functional updaters bypass the closure.
  6. Cleanup + local flag is the canonical race-condition fix for fetches in effects.
  7. useRef returns the SAME object every render — that's why mutating .current doesn't re-render.
  8. useCallback is useMemo of a function. useReducer is useState plus a reducer call. Hooks compose.
React hooks deep dive · Frontend Field Guides

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

Keep reading