Mastering React’s useEffect Hook from the Inside Out
React’s useEffect hook is easy to use but complex under the hood. It is responsible for synchronizing your component with external systems: subscriptions, timers, network requests, DOM mutations, and more. Understanding how useEffect behaves—and even how to re‑implement it—provides deep insight into React’s runtime, rendering pipeline, and the Fiber architecture.
This article explains how useEffect works, how dependency tracking operates, how React schedules and cleans up effects, and how you can manually build a simplified version of the hook. The goal is not to recreate React, but to understand the principles that guide one of its core systems.
1. How useEffect Fits into React’s Rendering Model
React’s rendering is pure: components return UI descriptions based solely on props and state. Side effects break purity, so React defers them until after the DOM is committed. useEffect is the mechanism that schedules these side effects.
useEffect(create, deps) receives two parameters:
- create: a function that runs after render; it may return a cleanup function
- deps: an optional dependency array that controls when the effect re-runs
Basic usage:
useEffect(() => {
console.log("Mounted");
return () => console.log("Unmounted");
}, []);The effect runs on mount and its cleanup runs on unmount. When dependencies are included, React compares them using Object.is to decide whether to schedule updates.
2. The Lifecycle of a useEffect Call
React breaks effect execution into three phases:
2.1 Mount
- The effect is stored in a linked list of hooks associated with a component’s Fiber.
- Since this is the first render, the effect always runs.
2.2 Update
- The previous effect’s dependency list is retrieved.
- Dependencies are compared.
- If values changed, cleanup runs first, then the new effect.
2.3 Unmount
- All registered cleanup functions are executed.
This model ensures predictable behavior even in concurrent rendering modes.
3. Understanding Hook Storage with Fibers and Linked Hooks
React stores hook state in a linked list attached to a Fiber node. During rendering, each hook call advances a pointer along this list, guaranteeing call ordering.
A simplified Hook structure:
function Hook() {
this.memoizedState = null;
this.next = null;
this.queue = [];
}And a minimal Fiber:
function FiberNode() {
this.updateQueue = null;
this.alternate = null;
}The design ensures determinism: hooks must always be called in the same order.
4. Implementing the Mount Phase of useEffect
During mount, React:
- Creates a Hook object
- Stores the dependencies
- Pushes an “effect descriptor” into the component’s effect queue
Simplified implementation:
function pushEffect(tag, create, deps, destroy = null) {
const effect = { tag, create, deps, destroy };
if (!currentlyRenderingFiber.updateQueue) {
currentlyRenderingFiber.updateQueue = [];
}
currentlyRenderingFiber.updateQueue.push(effect);
}Mounting logic:
function mountEffect(create, deps) {
const hook = mountWorkInProgressHook();
hook.memoizedState = pushEffect(
HookPassive | HookHasEffect,
create,
deps
);
}Every mount effect has the HasEffect flag, ensuring it executes after commit.
5. Updating useEffect: Dependency Comparison and Scheduling
During updates, React retrieves the old hook from the fiber’s alternate tree:
function updateWorkInProgressHook() {
currentHook = currentHook
? currentHook.next
: currentlyRenderingFiber.alternate.memoizedState;
const hook = new Hook();
hook.memoizedState = currentHook.memoizedState;
hook.queue = currentHook.queue;
if (!workInProgressHook) {
currentlyRenderingFiber.memoizedState = hook;
} else {
workInProgressHook.next = hook;
}
workInProgressHook = hook;
return hook;
}Dependency equality check:
function areHookInputsEqual(nextDeps, prevDeps) {
if (!nextDeps || !prevDeps) return false;
for (let i = 0; i < nextDeps.length; i++) {
if (!Object.is(nextDeps[i], prevDeps[i])) {
return false;
}
}
return true;
}Updating logic:
function updateEffect(create, deps) {
const hook = updateWorkInProgressHook();
const prevEffect = hook.memoizedState;
if (deps && areHookInputsEqual(deps, prevEffect.deps)) {
hook.memoizedState = pushEffect(
HookPassive,
create,
deps,
prevEffect.destroy
);
} else {
currentlyRenderingFiber.flags |= HookPassive;
hook.memoizedState = pushEffect(
HookPassive | HookHasEffect,
create,
deps,
prevEffect.destroy
);
}
}This mirrors React’s real diffing: unchanged dependencies skip the create function entirely.
6. Running Cleanup Functions
Before effects re-run or when a component unmounts, cleanups execute:
function commitHookPassiveUnmountEffects(fiber, hookFlags) {
const queue = fiber.updateQueue;
if (queue) {
queue.forEach(effect => {
if (effect.tag & hookFlags && effect.destroy) {
effect.destroy();
effect.destroy = null;
}
});
}
}React guarantees:
- cleanup always happens before the next effect
- cleanup always happens on unmount
- cleanup never runs in parallel with rendering
7. Complete Mini-Implementation of useEffect
Here is the full, simplified version of useEffect:
function useEffect(create, deps) {
const current = currentlyRenderingFiber.alternate;
if (!current) {
mountEffect(create, deps);
} else {
updateEffect(create, deps);
}
}With all structures combined, this represents most of the conceptual flow React uses internally.
Conclusion
Re-implementing useEffect clarifies why it behaves the way it does: from the linked-list storage of hooks, to React’s scheduling strategy, to cleanup behavior and dependency tracking. Once you understand these internals, patterns like “stale closures,” dependency arrays, and rerender behavior become intuitive.
This knowledge elevates your ability to debug React code, predict effect execution order, and write reliable components—whether you’re working in classic React apps, server components, or concurrent rendering environments.