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
- What a hook actually is
- Why effects are called "side effects"
- How useState works internally (with our own implementation)
- How useEffect works internally
- Why hooks can't be conditional — the array trick
- Async functions in useEffect — the right pattern
- Taming the async-state-update trap (stale closures, cleanup)
- Building a custom hook in 3 steps
- Custom implementations of all 9 hooks from the cheat sheet
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):
useState, useEffect, useRef, and your custom hooks are all variations on this one idea.
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.
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.
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.
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
useEffect(fn)useEffect(fn, [])Object.is comparison.useEffect and useLayoutEffect: useEffect runs after the browser paints. useLayoutEffect runs synchronously after DOM mutations but before paint. Same internal mechanism — different scheduling.
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.
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 }
Fig. 2 — When the cursor count drifts between renders, hook state corrupts. React detects this and throws.
The actual rules, restated
- Only call hooks at the top level. Not inside loops, conditions, or nested functions — anything that could change call order between renders.
- Only call hooks from React functions. Components or custom hooks (which are themselves React functions).
- The
eslint-plugin-react-hookslinter 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.
You can't mark useEffect itself async
A common first attempt that React rejects:
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.
useEffect(() => { async function run() { const data = await fetch('/api'); setData(data); } run(); }, []);
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.
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.
useEffect(() => { fetch('/api').then(r => r.json()).then(setData); }, []); // If component unmounts before fetch resolves: warning, leak
useEffect(() => { let cancelled = false; fetch('/api') .then(r => r.json()) .then(data => { if (!cancelled) setData(data); }); return () => { cancelled = true; }; }, []);
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.
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
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.
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.
A custom hook is just a function
No registration, no API. Three simple rules and you're done.
use.[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.
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
- "Side effect" is FP vocabulary — anything besides returning a value. React adopted the term verbatim.
- Hooks are an array indexed by call order. That's the whole trick.
- Rules of hooks exist to keep the index stable across renders. Linter enforces statically.
- useEffect can't be async — the return slot is reserved for cleanup, not a Promise.
- Stale closures happen when async callbacks capture old state. Functional updaters bypass the closure.
- Cleanup + local flag is the canonical race-condition fix for fetches in effects.
- useRef returns the SAME object every render — that's why mutating
.currentdoesn't re-render. - useCallback is useMemo of a function. useReducer is useState plus a reducer call. Hooks compose.
Before you leave — how confident are you with this?
Your honest rating shapes when you'll see this again. No grades, no shame.
Comments
Loading comments…