React Hooks constraints explained

The introduction of React Hooks has changed how we wrote React components by going away with Class Components and choosing to use Function Components instead. Furthermore, they also come with interesting constraints such as requiring hooks to be called on the top level and only allowing hooks to be called inside React components. Why is this so? In order to figure this out, I dug into React's source code and learned a couple of things.

In React's fiber architecture, a fiber represents a piece of work. These pieces of work, called fibers, are then chained together like a linked list. In React's world, a piece of work could be something like rendering a component, which React processes and renders to the DOM.

Hooks are also represented by a fiber, a piece of work to be done. Since we just learned that fibers are chained together as a link list, let's look at an example to understand what is happening underneath:

For those who want a more in-depth explanation, Andrew Clark from React's core team has written more about React's fiber architecture. It is a great read and I highly recommend it.

function App() {
  const [greeting, setGreeting] = React.useState('hi');
  React.useEffect(() => {
    setGreeting('hello world');
  }, []);
  return <div>{greeting}</div>;
}

On the very first render, React will have 3 pieces of work created - one to run useState, another to run useEffect and the last one to render the output to DOM.

After the useState hook has ran, React will store the initial value hi in its corresponding fiber. Next, as useEffect has no state, React instead stores its dependencies, which is [] in this case.

In the very next render, the same linked list is used. useState will retrieve its value from the first fiber. Then, useEffect will receive [] as its dependencies from the second fiber and skip running the effect as there are no differences between its current and previous dependencies.

Now what happens if we violate the rules of hooks and decide to stop calling hooks at the top level?

function App() {
  const [greeting, setGreeting] = React.useState('hi');
  if (greeting === 'hi') {
    React.useEffect(() => {
      setGreeting('hello world');
    }, []);
  }
  return <div>{greeting}</div>;
}

During the second render, the if condition will be false and React finds out that it has an extra useEffect fiber that isn't getting used. Nothing makes sense at this point, so React errors out and complains that you have done something wrong.

So why do we have to call hooks at the top level? As we have learned, React relies on the call order to retrieve the fiber in the linked list, so calling hooks from other places will mess the order up and prevent work from getting done.

Hooks being tied to the fiber architecture also means that hooks are very similar to components in nature. Let's take a look at react-router's ScrollToTopOnMount example component:

class ScrollToTopOnMount extends Component {
  componentDidMount() {
    window.scrollTo(0, 0);
  }

  render() {
    return null;
  }
}

In this ScrollToTopOnMount component, we are triggering a side-effect when it is mounted but not drawing anything to the DOM during the render phase. Let's take a look how we would rewrite it using hooks:

function ScrollToTopOnMount() {
  React.useEffect() => {
    window.scrollTo(0, 0);
  }, []);
  return null;
}

In this function version of ScrollToTopOnMount, we also trigger scroll on mount and renders nothing to the DOM. If your eyes are sharp, you may realize that by simply renaming it, we can turn the component into a custom hook!

function useScrollToTopOnMount() {
  React.useEffect() => {
    window.scrollTo(0, 0);
  }, []);
  return null;
}

This goes to show that hooks are really not much different from React function components. If it weren't for the React.createElement function that JSX so conveniently abstracts for us, perhaps more people will realize that hooks are just components without the divs and spans being flushed to the DOM.

As hooks are tied to the React component lifecycle just like other components, it explains why hooks have to be called in React components. If it was called in other places, React would not have any way of managing them.