React Hooks constraints explained

Published - 4 min read

The introduction of React Hooks has changed how we write React components by moving away from Class Components and choosing to use Function Components instead. Furthermore, they come with interesting constraints, such as requiring hooks to be called at the top level and only allowing hooks to be called inside React components. Why is this so? 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 linked 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.

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

On the very first render, React will create three pieces of work: one to run useState, another to run useEffect, and the last one to render the output to the DOM.

After the useState hook has run, React will store the initial value hi in its corresponding fiber. Next, since useEffect has no state, React instead stores its dependencies, which are [] 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?

jsx
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 discovers that it has an extra useEffect fiber that isn’t being utilized. At this point, nothing makes sense, so React errors out and indicates that you have done something wrong.

So, why must we call hooks at the top level? As we have learned, React relies on the call order to retrieve the fiber in the linked list; therefore, calling hooks from other locations will disrupt the order and prevent work from being completed.

The fact that hooks are 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:

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

  render() {
    return null;
  }
}

In this ScrollToTopOnMount component, we trigger a side effect when it is mounted, but we do not draw anything to the DOM during the render phase. Let’s take a look at how we would rewrite it using hooks:

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

In this function version of ScrollToTopOnMount, we also trigger a scroll on mount and render nothing to the DOM. If you pay close attention, you may notice that by simply renaming it, we can transform the component into a custom hook!

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

This demonstrates that hooks are not much different from React function components. If it weren’t for the React.createElement function that JSX conveniently abstracts for us, perhaps more people would realize that hooks are simply components without the divs and spans being rendered to the DOM.

Since hooks are tied to the React component lifecycle just like other components, this explains why hooks must be called in React components. If they were called in other places, React would have no way of managing them.