DEV Community

Cover image for 🚀 Rebuilding useEffect From Scratch to Truly Understand React
chandra penugonda
chandra penugonda

Posted on

🚀 Rebuilding useEffect From Scratch to Truly Understand React

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]))
Enter fullscreen mode Exit fullscreen mode

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(() => {}, [{}]);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

React stores cleanup functions alongside dependency arrays.

On dependency change:

  1. Run previous cleanup
  2. Execute new effect
  3. 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();
Enter fullscreen mode Exit fullscreen mode

Effect runs during render.

In real React:

  1. Render phase (pure, no side effects)
  2. Commit phase (DOM mutations applied)
  3. 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()
Enter fullscreen mode Exit fullscreen mode

This separation is what enables concurrency.


5️⃣ Why Resetting Hook Index Is Required

In our simplified runtime:

resetHookIndex();
Enter fullscreen mode Exit fullscreen mode

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Implement myUseState
  2. Add a render loop
  3. Queue effects instead of running immediately
  4. Simulate commit phase
  5. Add batching logic
  6. 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)