Implement Simplified React Hooks
Why Build Your Own Hooks?
Every React developer uses hooks. Very few understand how they work. And that's exactly what FAANG interviewers want to find out.
When an interviewer asks you to "implement a simplified useState," they're not testing whether you can write React code. They're testing whether you understand closures, mutable state containers, the importance of call order, and the design decisions that make React's API possible.
We're going to build all five core hooks from scratch: useState, useEffect, useMemo, useRef, and useCallback. By the end, you'll have a working mental model of React's hooks system that will survive any interview question thrown at you.
Think of React's hooks system as a numbered row of lockers. Every time a component renders, React walks down the row from locker 0, opening each one in order. Each hook call says "give me the next locker." That's why you can't call hooks conditionally — if you skip a locker, every hook after it opens the wrong one and grabs someone else's stuff.
The Hooks Array: Where the Magic Lives
Before we build individual hooks, we need to understand the data structure underneath all of them. React stores hook state in an array (technically a linked list in the real implementation, but an array captures the same core idea).
let hooks = [];
let currentHookIndex = 0;
function resetHookIndex() {
currentHookIndex = 0;
}
That's it. Two variables. Every hook you've ever used — useState, useEffect, useMemo, useRef, useCallback — is just a function that reads from and writes to a specific slot in this array, identified by the order it was called.
Each time a component renders, currentHookIndex resets to 0. Each hook call reads from hooks[currentHookIndex], does its thing, then increments currentHookIndex. On the next render, it walks the same array in the same order.
Building useState
useState is the most important hook to understand. It needs to do three things: store state across renders, return the current value, and provide a setter that triggers a re-render.
function useState(initialValue) {
const index = currentHookIndex;
if (hooks[index] === undefined) {
hooks[index] = initialValue;
}
function setState(newValue) {
if (typeof newValue === "function") {
hooks[index] = newValue(hooks[index]);
} else {
hooks[index] = newValue;
}
rerender();
}
currentHookIndex++;
return [hooks[index], setState];
}
Let's break down the key decisions:
Closure over index: We capture currentHookIndex into index at call time. This is critical. By the time setState is called, currentHookIndex has moved on to the next hook. But index is frozen via closure — it always points to this hook's slot.
Initializer check: On the first render, hooks[index] is undefined, so we store the initial value. On subsequent renders, we skip this and return whatever's already stored.
Functional updates: setState(prev => prev + 1) is supported by checking if the argument is a function. If so, we call it with the current value. This matters when multiple setState calls happen in the same event handler.
Trigger re-render: After updating state, we call rerender() — a function that re-executes the component. In our simplified version, this just calls the component function again after resetting the hook index.
let component;
function rerender() {
resetHookIndex();
component();
}
In real React, setState does not trigger an immediate re-render. It schedules an update through React's reconciler, which batches multiple state updates into a single render. Our simplified version calls rerender() synchronously for clarity, but keep in mind that production React uses a priority-based scheduler with lanes for concurrent rendering.
Building useEffect
useEffect is trickier than useState because it has three behaviors: run on mount, run when dependencies change, and run a cleanup function before re-running or unmounting.
function useEffect(callback, deps) {
const index = currentHookIndex;
const prevDeps = hooks[index]?.deps;
const prevCleanup = hooks[index]?.cleanup;
let hasChanged = true;
if (prevDeps !== undefined) {
hasChanged = deps === undefined || deps.some(
(dep, i) => !Object.is(dep, prevDeps[i])
);
}
if (hasChanged) {
if (typeof prevCleanup === "function") {
prevCleanup();
}
const cleanup = callback();
hooks[index] = { deps, cleanup };
}
currentHookIndex++;
}
The key pieces:
Dependency comparison with Object.is: React uses Object.is — not === — to compare dependencies. The difference? Object.is(NaN, NaN) is true (while NaN === NaN is false), and Object.is(+0, -0) is false (while +0 === -0 is true). For most cases they behave identically, but React chose Object.is for correctness.
Cleanup tracking: The return value of the callback is stored as cleanup. Before re-running the effect, we call the previous cleanup. This is how event listeners, timers, and subscriptions get properly torn down.
No deps vs empty deps: If deps is undefined (you didn't pass a second argument), the effect runs on every render. If deps is [], the effect runs once on mount and never re-runs (because an empty array never has items that change).
A subtle bug in many simplified implementations: forgetting to handle deps.length mismatch. If deps goes from [a, b] to [a] between renders, comparing index-by-index without checking length means you'd miss that b was removed. In practice, React warns about this and the linter catches it, but it's worth knowing why changing the number of dependencies is a bug — it means your hook call is fundamentally different between renders.
Building useMemo
useMemo caches the result of an expensive computation and only recalculates when dependencies change. Structurally, it's very similar to useEffect — the difference is that useMemo returns a value synchronously instead of scheduling a side effect.
function useMemo(factory, deps) {
const index = currentHookIndex;
const prev = hooks[index];
let hasChanged = true;
if (prev !== undefined) {
hasChanged = deps.some(
(dep, i) => !Object.is(dep, prev.deps[i])
);
}
if (hasChanged) {
const value = factory();
hooks[index] = { value, deps };
currentHookIndex++;
return value;
}
currentHookIndex++;
return prev.value;
}
The pattern is almost identical to useEffect: store deps, compare with Object.is, recompute if changed. The difference is that useMemo calls factory() synchronously and returns the result, while useEffect calls callback() asynchronously (after render, in real React).
Why deps are required: Unlike useEffect where omitting deps means "run every render," useMemo always requires deps. Without deps, you'd recompute every render — defeating the entire purpose of memoization.
Building useRef
useRef is the simplest hook. It's just a persistent mutable container that survives re-renders. No dependency tracking, no comparison, no cleanup. Just a box with a .current property.
function useRef(initialValue) {
const index = currentHookIndex;
if (hooks[index] === undefined) {
hooks[index] = { current: initialValue };
}
currentHookIndex++;
return hooks[index];
}
That's the entire implementation. On the first render, create an object with a current property. On every subsequent render, return the same object. The object reference stays stable across renders — which is why mutating .current doesn't trigger a re-render. React doesn't even know you changed it.
useRef is a sticky note attached to the component. You can write whatever you want on it, erase it, rewrite it. React doesn't look at the note, doesn't care what's on it, and never makes a copy. It just makes sure the same sticky note is there every time the component renders.
This is exactly why useRef is perfect for storing DOM references, previous values, timer IDs, and any mutable value that should persist but shouldn't trigger renders.
Building useCallback
Here's a fact that trips up many React developers: useCallback is just useMemo for functions. It's syntactic sugar.
function useCallback(callback, deps) {
return useMemo(() => callback, deps);
}
That's it. One line. useCallback(fn, deps) is identical to useMemo(() => fn, deps).
The factory function passed to useMemo is () => callback — a function that returns the callback. If deps haven't changed, useMemo returns the previously cached function reference. If deps changed, the factory runs again and returns the new callback.
Why does this exist as a separate hook? Readability. Writing useMemo(() => fn, deps) when you want to memoize a function looks confusing — it's a function returning a function. useCallback(fn, deps) makes the intent explicit: "memoize this function."
The Complete Implementation
Here's everything together — a minimal but functional hooks system:
let hooks = [];
let currentHookIndex = 0;
let component;
function resetHookIndex() {
currentHookIndex = 0;
}
function rerender() {
resetHookIndex();
component();
}
function useState(initialValue) {
const index = currentHookIndex;
if (hooks[index] === undefined) {
hooks[index] = initialValue;
}
function setState(newValue) {
if (typeof newValue === "function") {
hooks[index] = newValue(hooks[index]);
} else {
hooks[index] = newValue;
}
rerender();
}
currentHookIndex++;
return [hooks[index], setState];
}
function useEffect(callback, deps) {
const index = currentHookIndex;
const prevDeps = hooks[index]?.deps;
const prevCleanup = hooks[index]?.cleanup;
let hasChanged = true;
if (prevDeps !== undefined) {
hasChanged = deps === undefined || deps.some(
(dep, i) => !Object.is(dep, prevDeps[i])
);
}
if (hasChanged) {
if (typeof prevCleanup === "function") {
prevCleanup();
}
const cleanup = callback();
hooks[index] = { deps, cleanup };
}
currentHookIndex++;
}
function useMemo(factory, deps) {
const index = currentHookIndex;
const prev = hooks[index];
let hasChanged = true;
if (prev !== undefined) {
hasChanged = deps.some(
(dep, i) => !Object.is(dep, prev.deps[i])
);
}
if (hasChanged) {
const value = factory();
hooks[index] = { value, deps };
currentHookIndex++;
return value;
}
currentHookIndex++;
return prev.value;
}
function useRef(initialValue) {
const index = currentHookIndex;
if (hooks[index] === undefined) {
hooks[index] = { current: initialValue };
}
currentHookIndex++;
return hooks[index];
}
function useCallback(callback, deps) {
return useMemo(() => callback, deps);
}
Why Hooks Can't Be Called Conditionally
Now you can see exactly why the Rules of Hooks exist. Look at what happens when you call a hook conditionally:
function BrokenComponent({ showName }) {
const [count, setCount] = useState(0); // Always slot 0
if (showName) {
const [name, setName] = useState(""); // Slot 1 (sometimes)
}
useEffect(() => { // Slot 1 or 2??
console.log("count changed:", count);
}, [count]);
}
On render 2, useEffect reads from slot 1 — but slot 1 contains the name state from the conditional useState. The effect thinks its dependencies are '' instead of { deps: [0] }. Everything downstream is corrupted. React can't recover from this.
This is why the React linter enforces the Rules of Hooks. It's not a stylistic preference — it's a structural constraint of the array-based storage model.
How This Differs from Real React
Our implementation captures the core ideas, but real React is more sophisticated in several ways:
Linked list vs array: React Fiber uses a linked list of hook nodes attached to the fiber node, not an array. Each hook node points to the next. The effect is the same — order-dependent traversal — but linked lists allow React to efficiently insert and remove nodes during concurrent rendering.
Batched updates: Our setState calls rerender() immediately. Real React batches all state updates within an event handler into a single re-render. Since React 18, this automatic batching extends to promises, timeouts, and native event handlers too.
Effect scheduling: Our useEffect runs the callback synchronously during render. Real React schedules effects to run after the browser has painted — using a microtask or requestIdleCallback-like mechanism. useLayoutEffect runs synchronously after DOM mutations but before paint.
Priority lanes: React 18+ assigns priority lanes to updates. A startTransition update has lower priority than a direct setState. Our implementation treats all updates equally.
Interview Tips
When implementing hooks in an interview, start with the hooks array and currentHookIndex. This shows the interviewer you understand the foundational design before writing any individual hook.
Build useState first — it demonstrates closures, mutable state, and re-rendering. Then useEffect to show dependency comparison and cleanup. If there's time, show that useCallback is just useMemo wrapping a function — interviewers love that one-liner because it shows deep understanding.
Always explain why hooks can't be called conditionally. Don't just say "it's a rule" — walk through the slot corruption example. This is where most candidates separate themselves.
| What developers do | What they should do |
|---|---|
| Using === instead of Object.is for dependency comparison NaN === NaN is false, which would cause effects with NaN dependencies to re-run every render. Object.is(NaN, NaN) is true. | Use Object.is to handle NaN and signed zero correctly |
| Forgetting to capture currentHookIndex in a local variable inside useState The setter is called later, when currentHookIndex has already moved to the last hook's index. Without a local copy, every setter updates the wrong slot. | Copy currentHookIndex to a const before creating the setter closure |
| Running useEffect callbacks synchronously during render Synchronous effects block rendering and can cause visual glitches. React defers them to avoid blocking the browser's paint cycle. | In real React, effects run after paint. Mention this distinction in interviews even if your implementation is synchronous |
| Thinking useCallback and useMemo are fundamentally different hooks They share the same memoization mechanism. useCallback exists only for readability — it makes the intent of memoizing a function explicit. | useCallback(fn, deps) is exactly useMemo(() => fn, deps) |
| Storing useRef values as primitives instead of objects with a current property If useRef returned a primitive, there would be no way to mutate it from outside. The object wrapper provides a stable reference whose .current property can be freely mutated without triggering re-renders. | useRef returns { current: value } — an object reference that stays stable across renders |
- 1Hooks state lives in an ordered array (or linked list) — each hook claims the next slot based on call order
- 2currentHookIndex resets to 0 on every render, so hooks must always be called in the same order
- 3useState captures its array index via closure — this is why the setter always updates the correct slot
- 4useEffect compares dependencies with Object.is, runs cleanup before re-running, and stores the cleanup function
- 5useCallback is syntactic sugar: useCallback(fn, deps) === useMemo(() => fn, deps)
- 6useRef is the simplest hook — just a persistent { current: value } object that survives re-renders without triggering them
- 7Conditional hook calls corrupt the index-to-hook mapping, breaking every hook that comes after the conditional one