Inside React: Rebuilding useEffect to Understand Hook Internals
Most developers can explain what useEffect does.
Very few can explain how React makes it work internally.
This article is not about usage.
It’s about rebuilding a minimal hook runtime to understand:
- How React tracks hooks across renders
- Why hook order is mandatory
- How dependency arrays actually work
- Why effects run after commit (not during render)
- Where React’s real complexity lives
Let’s build a simplified version of useEffect and dissect it.
Step 1 — A Minimal Hook Runtime
Here’s a simplified custom implementation:
const hooks = [];
let currentHookIndex = 0;
function myUseEffect(effect, deps) {
const hookIndex = currentHookIndex;
const oldHook = hooks[hookIndex];
let shouldRun = false;
if (!deps) {
shouldRun = true;
} else if (!oldHook) {
shouldRun = true;
} else {
const prevDeps = oldHook.deps;
shouldRun =
deps.length !== prevDeps.length ||
prevDeps.some((ol, i) => !Object.is(ol, deps[i]));
}
if (shouldRun) {
if (oldHook?.cleanup) oldHook.cleanup();
const cleanup = effect();
hooks[hookIndex] = {
deps,
cleanup
};
} else {
hooks[hookIndex] = oldHook;
}
currentHookIndex++;
}
function resetHookIndex() {
currentHookIndex = 0;
}
Now we use it inside a React component:
export default function App() {
const [counter, setCounter] = useState(0);
resetHookIndex();
myUseEffect(() => {
console.log(counter);
return () => console.log("cleanup");
}, []);
return (
<button onClick={() => setCounter(counter + 1)}>
{counter}
</button>
);
}
Now let’s analyze what this reveals about React internals.
1️⃣ Hooks Are Indexed Slots, Not Named Bindings
The most important line:
const hookIndex = currentHookIndex;
Hooks are not tracked by variable name.
They are tracked by call order.
Internally, React stores hooks as a linked list attached to a Fiber node. Conceptually, though, it behaves like this:
Render 1:
Slot 0 → useState
Slot 1 → useEffect
Render 2:
Slot 0 → useState
Slot 1 → useEffect
If order changes, slot mapping breaks.
This explains the fundamental rule:
Hooks must be called unconditionally and in the same order.
This is not stylistic.
It’s architectural.
2️⃣ Dependency Arrays Are Deterministic Equality Checks
This logic:
deps.length !== prevDeps.length ||
prevDeps.some((old, i) => !Object.is(old, deps[i]))
That’s essentially what React does.
Important observations:
- Comparison uses
Object.is - Objects are compared by reference
- No deep comparison
- Length mismatch triggers re-run
So this:
useEffect(() => {}, [{}]);
Always re-runs.
Because each render creates a new object reference.
The “magic” is just shallow reference comparison.
3️⃣ Cleanup Is Just Stored State
Notice:
if (oldHook?.cleanup) oldHook.cleanup();
React stores cleanup functions alongside dependency arrays.
On dependency change:
- Run previous cleanup
- Execute new effect
- Store returned cleanup
On unmount:
React iterates through all effect hooks and runs their cleanup.
There is no mystery.
It’s just stored state and deterministic lifecycle transitions.
4️⃣ The Critical Difference: Render vs Commit
Here’s where our implementation diverges from real React.
In our version:
const cleanup = effect();
Effect runs during render.
In real React:
- Render phase (pure, no side effects)
- Commit phase (DOM mutations applied)
- Passive effects (
useEffect) executed
Why does React separate this?
Because React supports:
- Interruptible rendering
- Concurrent mode
- Multiple render passes before commit
- Aborted renders
If effects ran during render:
- Side effects could fire for aborted renders
- Subscriptions could leak
- State updates could cascade incorrectly
React instead builds an effect list during render and flushes it after commit.
Conceptually:
render()
collectEffects()
commit()
flushEffects()
This separation is what enables concurrency.
5️⃣ Why Resetting Hook Index Is Required
In our simplified runtime:
resetHookIndex();
We manually reset before each render.
React does this internally when it begins rendering a fiber.
During render:
- A pointer walks through hook slots
- Each hook call advances the pointer
- After render completes, pointer resets
This is how state persists across renders while still maintaining order.
6️⃣ Where React’s Real Complexity Lives
The hook logic itself is simple.
The hard part is:
- Fiber tree reconciliation
- Cooperative scheduling
- Priority lanes
- Interruptible rendering
- Effect queues
- Batching
Hooks are just a deterministic layer on top of Fiber.
The true engine is the scheduler + reconciliation algorithm.
7️⃣ A Subtle Edge Case: State Updates Inside Effects
Consider:
myUseEffect(() => {
setCounter(c => c + 1);
}, []);
In our implementation:
- Effect runs during render
- State updates synchronously
- Potential infinite loops
In real React:
- Effect runs after commit
- State update schedules a new render
- Batching prevents infinite loops
- Strict Mode may double-invoke in dev
This difference shows why phase separation matters.
8️⃣ Why This Exercise Matters for Senior Engineers
Understanding this changes how you think about:
- Stale closures
- Dependency bugs
- Infinite render loops
- Memoization
- Performance issues
- React Strict Mode behavior
It also sharpens your mental model for:
- Runtime systems
- Deterministic execution
- Slot-based state machines
- Referential equality
- Scheduling systems
This is the difference between:
Framework user
vs
Framework-aware engineer
9️⃣ Extending This Experiment
If you want to go deeper:
- Implement
myUseState - Add a render loop
- Queue effects instead of running immediately
- Simulate commit phase
- Add batching logic
- Model multiple component instances
At that point, you’ll start thinking like React’s runtime.
Final Takeaway
React Hooks are not magic.
They are:
- Indexed state slots
- Shallow dependency comparisons
- Deterministic lifecycle transitions
- Executed in strict render order
The complexity of React is not in the API.
It’s in the architecture that enables scheduling, concurrency, and deterministic rendering.
Once you see that, debugging React becomes much easier.
And your engineering intuition levels up permanently.
Top comments (0)