React Hooks constraints explained
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.
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?
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:
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:
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!
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 div
s and span
s 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.