Why are the rules of hooks rules?

If you've written React code in the past few years, you may have come across the Rules of Hooks.

Hooks are special functions that start with use and must follow two specific rules:

  1. Don't call Hooks inside loops, conditions, or nested functions
  2. Don't call Hooks from regular JavaScript functions or from top-level scope

But why do those rules exist?

Not just "because the React developers say so"—what are the constraints that led React developers to decide to impose these rules upon your code?

A decorative photograph of a tile work at the Alhambra

But why?

This question honestly drove me a bit mad. What was the impossible-to-change implementation detail that requires me to structure my own code in a way that React says is right?

Programming is like plumbing. But instead of pipes and water, we use functions to move data from one place to another.

Data comes in from function parameters and from what's available in scope. Data goes out to the function's return value and to places your function takes the responsibility of managing (usually hidden from outside observers).

Ideally functions have the same outputs when given the same inputs. It's generally frowned upon to leak this data as a side effect, which can cause your function to behave differently on different invocations.

Aside from that, the way your function works is usually up to you. Want to return early, conditionally perform some computation, or use a *gasp* loop in your function? Go for it!

But React has these rules.

"What do you mean I can’t call a hook in an if statement? That’s my function, I should be able to write it the way I want!"

To illustrate, here's a perfectly reasonable (to me) component that is totally illegal according to React's hook rules:

import * as React from 'react';

/** @param numbers - an array of numbers */
export function SumSquared({ numbers }) {
  if (numbers.length === 0) {
    return <p>No numbers to compute!</p>;
  }
  const sumSquared = React.useMemo(() => {
    return numbers.reduce((acc, num) => acc + (num * num), 0);
  }, [numbers]);

  return <p>The sum squared is {sumSquared}.</p>
}

I want to answer two questions:

  1. Why are these rules necessary?
  2. What could React change so those rules don't need to exist?
A decorative photograph of a door with an intricate pattern at the Alhambra

Spoiler: it's the state

React components have a lifecycle:

  • On mount, the render function is called and the returned JSX is rendered as UI.
  • While mounted, when its state changes (or receives new props), the render function is called and the returned JSX is used to update the previously rendered UI.
  • On unmount, any cleanup functions the component has requested are called.

While mounted, each component instance can access the same state each time its render function is called.

But there's a catch: this state is not given to component functions as a parameter!

To accomplish this indirection, React exposes that state via special, built-in hooks.

There are many built-in hooks, but the "core" ones we'll talk about are:

  • useState() - get and set the same piece of granular state
  • useMemo() - retrieve a memoized value
  • useEffect() - call a function when values change between different updates

Let's build this kind of API to both understand it a bit better and maybe even find a better way!

Building a hook-style API

For this, all we care about is mounting, updating, and unmounting a component. We don't need to concern ourselves with props, updating the DOM, or dealing with JSX.

Here's a mount() function that simulates how React deals with this component lifecycle and a single useState() hook:

let activeInstance = undefined;

export function mount(Component) {
  // The component's instance state
  const instance = {
    Component,
    initialized: false,
    hookStateIndex: 0,
    hookState: [],
  };

  // Render the component
  render(instance);
  instance.initialized = true;

  // Unmount (currently no-op)
  return () => {};
}

function render(instance) {
  // A component may render while another is rendering
  const prevInstance = activeInstance;

  activeInstance = instance;

  // Reset execution index before calling
  activeInstance.hookStateIndex = 0;
  activeInstance.Component();

  // Restore previous active instance
  activeInstance = prevInstance;
}

export function useState(initialValue) {
  if (activeInstance === undefined) {
    throw new Error("Invariant: useState() called outside of a component!");
  }
  const instance = activeInstance;
  let slot;
  if (!instance.initialized) {
    // On first render, add new slot to the execution index
    slot = {
      type: "useState",
      value: typeof initialValue === "function" ? initialValue() : initialValue,
      setValue: (setter) => {
        let newValue;
        if (typeof setter === "function") {
          newValue = setter(slot.value);
        } else {
          newValue = setter;
        }
        if (slot.value !== newValue) {
          // Trigger rerender on state change
          slot.value = newValue;
          render(instance);
        }
      },
    };
    instance.hookState.push(slot);
  } else {
    // On update, get next slot state from execution index
    slot = instance.hookState[instance.hookStateIndex];
    instance.hookStateIndex += 1;
    if (!slot || slot.type !== "useState") {
      throw new Error("Invariant: you broke the rules!");
    }
  }
  return [slot.value, slot.setValue];
}

Even though this is a fairly small amount of code, we have encountered the constraints that lead to both rules!

In mount(), we initialize an instance state for the component throughout its lifecycle.

In order to know which component is being called, we store this instance state in a global variable. This allows useState() to get the currently-being-called component's instance state.

This leads us to our first rule:

  1. Don't call Hooks inside loops, conditions, or nested functions

Hook functions aren't given enough information to know which component instance is currently rendering. So the currently rendering component is stored in global state.

On first render, each call to useState() pushes a "slot" into hookState holding the granular state value and its setter. On subsequent renders, hookStateIndex is used to keep track of the current "slot" which corresponds to the number of calls to useState().

In order for the same "slot" to match the same call to useState(), each render and re-render must have the exact same sequence of useState() calls. Without that, the wrong value and setter could be returned from useState().

This leads us to our second rule:

  1. Don't call Hooks from regular JavaScript functions or from top-level scope

Hook functions aren't given enough information to know which piece of underlying state they refer to. So they cannot be called conditionally/in loops in order to make sure that the order of each hook execution remains the same over the lifetime of the component.

When a useState() setter is called, its value is updated and we render again, marking its instance in the global and re-executing the component.

Different hooks, different slots

We can now follow this same pattern to implement useMemo() and useEffect(), which feel very similar to useState():

export function useMemo(fn, dependencies) {
  if (activeInstance === undefined) {
    throw new Error("Invariant: useState() called outside of a component!");
  }
  const instance = activeInstance;
  let slot;
  if (!instance.initialized) {
    // On first render, add new slot to the execution index
    slot = {
      type: "useMemo",
      value: fn(),
      dependencies,
    };
    instance.hookState.push(slot);
  } else {
    // On update, get next slot state from execution index
    slot = instance.hookState[instance.hookStateIndex];
    instance.hookStateIndex += 1;
    if (!slot || slot.type !== "useMemo") {
      throw new Error("Invariant: you broke the rules!");
    }
    let cacheHit = true;
    for (let i = 0; i < dependencies.length; ++i) {
      if (dependencies[i] !== slot.dependencies[i]) {
        cacheHit = false;
        break;
      }
    }
    if (!cacheHit) {
      slot.dependencies = dependencies;
      slot.value = fn();
    }
  }
  return slot.value;
}

export function useEffect(fn, dependencies) {
  if (activeInstance === undefined) {
    throw new Error("Invariant: useState() called outside of a component!");
  }
  const instance = activeInstance;
  let slot;
  if (!instance.initialized) {
    // On first render, add new slot to the execution index
    slot = {
      type: "useEffect",
      cleanupFn: fn(),
      dependencies,
    };
    instance.hookState.push(slot);
  } else {
    // On update, get next slot state from execution index
    slot = instance.hookState[instance.hookStateIndex];
    instance.hookStateIndex += 1;
    if (!slot || slot.type !== "useEffect") {
      throw new Error("Invariant: you broke the rules!");
    }
    let cacheHit = true;
    for (let i = 0; i < dependencies.length; ++i) {
      if (dependencies[i] !== slot.dependencies[i]) {
        cacheHit = false;
        break;
      }
    }
    if (!cacheHit) {
      if (slot.cleanupFn) {
        slot.cleanupFn();
      }
      slot.dependencies = dependencies;
      slot.cleanupFn = fn();
    }
  }
  return slot.value;
}

Instead of a getter/setter pair, useMemo() holds the memoized value and the dependencies array passed in. On re-render, if the dependencies don't match, we re-execute the memoized function, save the new memoized value and update the dependencies array.

Similarly, useEffect() calls the effect function and stores a cleanup function & dependencies array. On re-render, if the dependencies don't match, it calls the cleanup function, calls the effect function, and then stores the new cleanup function and dependencies.

And we need one small change for useEffect() to do its job on unmount:

@@ -13,8 +13,14 @@ export function mount(Component) {
   render(instance);
   instance.initialized = true;

-  // Unmount (currently no-op)
-  return () => {};
+  // Unmount cleans up effects
+  return () => {
+    instance.hookState.forEach((slot) => {
+      if (slot.type === "useEffect" && slot.cleanupFn) {
+        slot.cleanupFn();
+      }
+    });
+  };
 }

 function render(instance) {

And there you have it: an API that roughly simulates the React hooks API. The full code can be seen on GitHub here.

Calling a spade a spade

I don't know about you, but this all feels like an ugly hack.

Like, if I asked for a code review at work which tracked function executions in an untyped array and said callers must adhere to a no-conditional-execution rule to avoid runtime type errors, that review would not be approved.

And yet we are fine with this in React?

And it's not just something swept under the rug. In practice, it also makes the debug ergonomics really weird.

Since React doesn't know which hook is which, it just references it by number! This is why the React devtools names hooks as numbers:

A screenshot of React Devtools showing a profile, which says 'Why did this render?' and 'Hooks 1, 4, and 5 changed'
This component was updated because hooks 1, 4 and 5 changed? What the heck are those?

Breaking the rules

Let's take a step back and ask ourselves some questions.

> Why are there rules?

Rules let us know which hook call corresponds to which hook state.

> Why are those rules needed?

Because user code doesn't give us enough information to know which piece of state each hook call corresponds to.

> Could we make user code give us that info?

Sure, we could change the API so we have enough information to know which piece of state each hook call corresponds to!

One way to change the API could be to have users give us a "key" parameter, which could be used as a dictionary key that maps to the same state value on each invocation.

Something like this:

import * as React from 'react';

/** @param numbers - an array of numbers */
export function SumSquared({ numbers }, hooks) {
  if (numbers.length === 0) {
    return <p>No numbers to compute!</p>;
  }
  const sumSquared = React.useMemo('sumSquared', () => {
    // useMemo's new, unique key - ^^^^^^^^^^^^
    return numbers.reduce((acc, num) => acc + (num * num), 0);
  }, [numbers]);

  return <p>The sum squared is {sumSquared}.</p>
}

This way, instead of keeping track of the number of calls to our core hooks and looking up the state based on that index, we could look up the hook's state in a dictionary we manage by this key.

Here's the change to make our hook implementation use this approach: diff full code

When a new key appears, we could initialize that hook. When a key is re-used, we can re-use the state. And when an update causes a previously used key to not be called, we could "clean up" the hook.

Instead of keeping track of the first initialization call, we initialize each slot when its key is first seen.

In order to "clean up" hooks that are conditionally called, we mark all the slots as inactive before calling the function and clean up the ones that are still inactive afterward.

The rest of the changes are a matter of using the key to access the slot instead of the hookStateIndex.

Why doesn't React do this?

I'm honestly not sure why React couldn't evolve to go in this direction. Heck, it already has a similar key concept when rendering arrays.

It would be a breaking change, so it would be hard. But breaking changes are not impossible!

It would require that all user-authored hooks also take a key parameter which could be used to build a unique key value into the hooks it calls.

And the benefits are huge: your component function could return early, use a hook in a loop, or do whatever else you want in your component!

But I'm doubtful it will happen. The React team is heavily invested in hooks. They're even building a compiler meant to improve the developer ergonomic issues that come as a natural consequence of this API.

Cynically, React has seemingly invented a problem (hooks) so that it can present an unnecessary innovation as a solution (React Compiler).

Become ungovernable

What would it look like to go further?

What if we took the time to really ask ourselves are the rules we impose on users necessary?

Imagine how much more flexible they could be. Imagine how much easier to understand they could be!

So let's take a hard look at React and ask ourselves:

What would a UI library look like if it didn't impose rules?

  • Maybe state could live anywhere...
  • Maybe HTML attributes like <button class="primary"> would just work...
  • Maybe custom elements could be made as easily as components...
  • Maybe custom DOM events and built-in events were treated the same...
  • Maybe items in an array could be fast without a key prop...
  • Maybe UI could be detached and later reattached without destroying and recreating it...
  • Maybe UI could even move around the DOM without destroying and recreating it...
  • Maybe useEffect() isn't needed at all...
  • Maybe a compiler isn't needed to automatically memoize calculations...
  • Maybe you don't even need to list out all these dependencies...

This isn't just a thought exercise. I've been working on a UI library that does all of these things.

It's a solid take on an alternative way of building a UI library that uses JSX, while feeling predictable.

It's called Gooey, check it out: https://gooey.abstract.properties

A decorative photograph of the ceiling of a courtyard, revealing the open sky at the Alhambra