If you've used React hooks along with the eslint-plugin-react-hooks
, you might have encountered the unexpected warning React Hook "useState" is called conditionally.
.
One can be quite surprised by this warning. At least I was.
Back then I had already seen a similar pattern with knockoutjs
and I though it was a pretty bad design flaw. I was puzzled a modern tool, like React, would allow for such a weakness.
So I decided to investigate and deep dive into how React Hooks work and why they cannot be conditioned.
TLDR
- the hook system is a quite big stateful machine that records every call to any hook (
useEffect
,useState
...); - at component's mount time all React hooks must be called so that they are registered against the hook system;
- the order at which React hooks are called in a component must be stable through time and renders.
Disclaimer
I ran my investigations against React v17
. Other versions might work differently (I am pretty sure it does not).
Also bear in mind that the code you are going to see in the article is not actual code from the React codebase. It's my own interpretation of what I read in there. The names of variables and functions have been changed for the sake of simplicity.
What happens when a hook function is called
Hook functions (useState
, useEffect
, ...) are rather interesting functions in that they are stateful at two levels:
- They mutate values in a global state;
- Depending when, in the component's lifecyle, they are called (
mount
,update
, ...) they do not run the same code.
At mount time
Let's take a very simple component as a working example:
function Component() {
const [first, setFirst] = useState("first");
const [second, setSecond] = useState("second");
return /*...*/;
}
When React
mounts this component, it will create a state that is associated with the instance of the component. In this state it will store, among other things, a linked list of all the hooks that have been called during the mounting of the component.
So when React
executes Component()
any call to a use*
function will create an entry in the linked list and we will end up with a state that looks like this:
{
value: "first",
next: {
value: "second",
next: null, // End of the linked list
},
};
Around a component initialisation, in React, we have code that looks like this:
function render(Component) {
// global variable that will be mutated by each and every use* call
global.currentComponentHooks = null;
// keeps track of the last hook that was mounted
global.lastMountedHook = null;
const children = Component();
// [...]
}
If we try to guess the body of the useState
(useEffect
would be, somewhat, similar) it should look something like:
function useState(value) {
// Creates the new entry
const hook = {
value: value,
next: null,
};
if (global.currentComponentHooks === null) {
// If it is the first entry, stores it in the global object
global.currentComponentHooks = hook;
} else {
// Add the current hook to the `next` property
global.lastMountedHook.next = hook;
}
// Keep track that this hook is the last one we mounted
global.lastMountedHook = hook;
// [...]
return [value, updateState]; // finally returns the initial value plus an update function
}
With the component we had earlier this is what the global.currentComponentHooks
will look like as lines are executed:
function Component() {
// null
const [first, setFirst] = useState("first");
// {value 'first', next: null}
const [second, setSecond] = useState("second");
// {value 'first', next: {value: 'second', next: null}}
return /*...*/;
}
At update time
Let's imagine something happened on the UI level and setFirst('updated')
was called.
I am not going to describe how the setFirst
function behaves. Just bear in mind that it is bound to the relevant entry in the linked list and simply mutates the value
property. It then triggers a re-rendering on React's side.
At mount time we have stacked our different hooks in a linked list. At update time we are going to traverse our linked list and read each stored value.
On React's side we need to create a pointer that will point to the root of our linked list before we re-render our component.
function update(Component) {
global.currentHook = global.currentComponentHooks; // {value 'first', next: {value: 'second', next: null}}
const children = Component();
// global.currentHook === null
}
It means that the body of the useState
function must have changed.
/* note that we do not need the initial value anymore */
function useState(/*value*/) {
const hook = global.currentHook;
global.currentHook = hook.next; // we move the pointer to the next hook
// [...]
return [hook.value, updateState];
}
And from our component's context this is how the currentHook
pointer evolves.
function Component() {
// {value 'updated', next: {value: 'second', next: null}}
const [first, setFirst] = useState("first"); // first === 'updated' our mutated state
// {value 'second', next: null}
const [second, setSecond] = useState("second"); // second === 'second' the unchanged initial state
return /*...*/;
}
So... Why can't we condition a hook?
Now that we have seen the internals of how hooks work, let's see what would happen if we conditioned one of them. A (arguably!) valid scenario for conditioning a hook would probably be triggering an effect
depending on some props. (Note that, just like useState
, useEffect
are also added the the linked list of all the hooks of a component)
const Component({doEffect}) {
const [first, setFirst] = useState(0);
if (doEffect) {
useEffect(/*...*/)
}
const [second, setSecond] = useState(0);
}
Now let's say that the component will mount with {doEffect: false}
. We will end up with that linked list:
{value: 0, next: {value: 0, next: null}}
We only have two elements and the useEffect
does not exist in our list.
If ever doEffect
switches to true
we will now try to access a hook that was not registered
function Component({ doEffect }) {
// {value: 0, next: {value: 0, next: null}}
const [first, setFirst] = useState(0);
if (doEffect) {
// {value: 0, next: null}
useEffect(/*...*/); // ⚠️ Wrong hook here
}
// null
const [second, setSecond] = useState(0); // ⚠️ No hook left!!
}
In order to fix this, we just need to move our condition inside the body of the useEffect
hook (and not forget to add it in the dependency of the useEffect
)
useEffect(() => {
if (doEffect) {
// Do your magic
}
}, [doEffect]);
Conclusion
React Hooks have quite changed how we write React apps. As a React user, I find being able to write only functional components (as opposed to class components) to be very pleasant!
But this comfort comes at a price:
- hooks feel like black magic and make the lifecycle of the component hard to grasp (and actually you should probably not think in lifecycles);
- we need stricts rules regarding how hooks are called.
I would also add that all those states inside the React codebase make the flow pretty hard to follow and understand. I can not thank enough my debugger for the step-by-step execution. Without it, this article would probably not exist 😅!!
Although the eslint-plugin-react-hooks
is almost always included in any React codebase, needing a special eslint
rule feels like a somewhat serious design flaw. That being said, and in my humble opinion, this is an acceptable tradeoff though :)