Hooks are an interesting new feature in React, but there are a few things about hooks that make it seem like a black-box magic solution. In this article, I'll deconstruct the magic behind React hooks.
React v16.8.0 has been released, which means that React Hooks are now available in a stable release of React. This is something I've been looking forward to, ever since I first started playing around with hooks in the alpha releases. I believe hooks are going to bring about a sea change in the way React components are written, and composed, and this is a change for the better.
If you have not yet heard of React hooks, you should probably go read this overview on hooks, on the React documentation site. Following that you might also want to read this excellent article on hooks by Dan Abramov.
Beware: Here there be Magic!
I've been talking about hooks a lot recently, and a lot of times, somebody points out that while hooks seem to be a great solution to the problem of composing side-effects, and state management in our React components, it also appears to be a bit of a black box, with unidentifiable magic happening to get it to actually work.
Most of these people justified their claims of hooks being magic due to:
- Hooks requiring to be called in the same static order during every render.
- Hooks can only be called from within other hooks or function components.
- The
useState
hook appears to track thestate
of the component, which was never passed to it.
While magic might be fun if you're at a show, it certainly is not fun when you're writing software, and for the life of you, cannot figure out why your library is doing something weird, unexpected, and defying all logic. So let's deconstruct the magic, and see what exactly is happening behind the scenes in each of these cases.
Let the UnMagicking Begin!
Hooks require to be called in the same static order during every render.
The first factor that people notice is that (as mentioned in the Rules for Hooks), hooks are very fussy about their call order. Specifically, React mandates that on every render, the order in which the hooks are called must be the same. This means that you can not call hooks inside loops or conditions, and instead they have to be called at the top-most level.
The reason behind this is quite simple. Consider a few hooks defined in a function component like this:
const [firstName, setFirstName] = useState('John');
const [lastName, setLastName] = useState('Doe');
const [age, setAge] = useState(28);
As you can see, this is a plain ES6 call to a function called useState
. The only information that the useState
function ever gets is the initial value that is passed into it (well not exactly, but we'll get back to that later). The setState
call then returns an array in the form of [value, setter]
.
On subsequent renders, the same useState
method will return the updated state value for that particular property. In order to do this, the useState
method will have to somehow keep track of each property requested. However, there is absolutely no place where we are passing a key
of any sort to the useState
method.
So how exactly does useState
know what state to return for which call? Well useState
uses the call sequence number inside the render function to track the state. So in the above example the first call to useState
will always return the value of firstName
and it's setter, the second call will always return the value of lastName
and it's setter and so on and so forth.
If like me, you are probably thinking that there would have to have been a much better way to implement this, instead of relying on call order, which seems quite hacky. Sebastian Markbage left an excellent comment on the React Hooks RFC, addressing that amongst other things. You should probably go read that too.
Anyway, the short of it is that if you're using hooks, you absolutely must call the hooks in the same order on every render, or Bad Things Will Happen, with React thinking you want to get or set a certain state value, when the one you want is entirely different.
An easy way to understand this is to think of useState
as maintaining a key-value map linking the call order to the state value. On the first render, useState
initializes the key-value map as:
const [firstName, setFirstName] = useState('John'); // internal state: {0: 'John'}
const [lastName, setLastName] = useState('Doe'); // internal state: {0: 'John', 1: 'Doe'}
const [age, setAge] = useState(28); // internal state: {0: 'John', 1: 'Doe', 2: 28}
On the second render, useState
already has a key-value map, so instead of setting values on it, it just retreives the values based on the call order. Please note, useState
does not actually maintain a simple key-value map, and the actual implementation is a bit more complicated than that. But for the purpose of understanding why the call order matters, you can think of it as effectively being a key-value map.
Hooks can only be called from within other hooks or function components.
Another big rule of hooks, is that you can only ever call a hook from a React function component, or from within other hooks. If you think about it, this makes complete sense – if your hook is going to have to get or set the state of a component, or trigger side-effects, either directly or indirectly, then it needs to be linked to a specific React component, to which that state would belong. Otherwise, how would React know which component's state your hook is trying to access?
So far all seems good. There's nothing really magicky about this one is there? Well if you want to see the magic, you need to break this rule, and call a hook from inside a normal Javascript function.
function catchMeIfYouCan(){
const [firstName, setFirstName] = useState('John');
};
Running this code immediately throws an error:
Uncaught Error: Hooks can only be called inside the body of a function component.
Now hold on a minute! If the useState
call is just a normal function call, and a function in Javascript can't really do much in the way of analyzing the method that called it, how did React know that the useState
method was being called from outside a React component? Magic? Not really, but since this ties into the next magicky item on our list, I'll address them both together.
The useState
hook appears to track the state
of the component, which was never passed to it.
Consider two components Student
and Teacher
, both of which have the state properties firstName
, lastName
and age
.
class StudentComponent extends React.Component{
constructor(props){
super(props);
this.state = {
firstName: 'Bobby',
lastName: 'Tables',
age: 8
}
}
render(){
return <div>{this.state.firstName} {this.state.lastName} ({this.state.age})</div>
}
}
class TeacherComponent extends React.Component{
constructor(props){
super(props);
this.state = {
firstName: 'John',
lastName: 'Keating',
age: 34
}
}
render(){
return <div>{this.state.firstName} {this.state.lastName} ({this.state.age})</div>
}
}
In the above example, in both of the components, state
is an instance property, and therefore can be accessed from within any of the class methods. Since it is an instance property and not a commonly shared object, the values are also encapsulated correctly and TeacherComponent
cannot access the state of StudentComponent
and vice-versa.
Now let's convert these components into their function component counterparts using the useState
hook.
function StudentComponent(props){
const [firstName, setFirstName] = useState('Bobby');
const [lastName, setLastName] = useState('Tables');
const [age, setAge] = useState(8);
return <div>{firstName} {lastName} ({age})</div>
};
function TeacherComponent(props){
const [firstName, setFirstName] = useState('John');
const [lastName, setLastName] = useState('Keating');
const [age, setAge] = useState(34);
return <div>{firstName} {lastName} ({age})</div>
};
If you notice here, in both of the components, we are calling the same useState
method, which is a method imported from the React library, and commonly shared amongst both the components. Like we discussed earlier, useState
uses only the call order to keep track of the state values.
Given this, if we render StudentComponent
first, setting the firstName
, lastName
and age
state properties, and then render TeacherComponent
immediately after it, then the TeacherComponent
should be able to access the state values set by the StudentComponent
right?
Well actually no. Even whilst using hooks, React ensures that the state
values are always properly encapsulated, and cannot be accessed by the wrong component. Otherwise, life wouldn't exactly be pleasant, with every component being able to access the state of all other components, would it?
But given that we are no longer using this
to encapsulate the state within an instance of a component, how can React perform this encapsulation for us? The answer to that is actually quite simple. But before we get to it, we need to make a slight detour into the internals of React. Or rather, the internals of React-DOM.
Better Rendering Performance with React Fiber
In the initial versions of React, rendering components was something that happened as a single continuous action. In a React application, a change to the state of the application triggers a re-rendering of the entire application. However, in practice, for any non-trivial application, re-rendering the entire application results in terrible performance.
In order to improve the performance, React performs quite a few optimizations. Most of these optimizations occur during the Reconciliation
stage. Reconciliation is basically the process by which React synchronizes the Virtual DOM that it has built up by executing the render
methods on each component instance, with the actual DOM that is constructed on the browser.
Prior to React Fiber (released in v16 of React), React relied on call stack to manage rendering. Whenever a component had to be re-rendered it was added to the call stack, and once the other items on the stack finished rendering, this component would render. However, while there were items on the stack, no other work could be done. Rendering would block all other functions, and rendering could not be broken up into smaller chunks and run.
In order to tackle this issue, Fiber was introduced. Fiber is basically a custom re-implementation of the call stack, which allows for things like splitting a big task into smaller work units, pausing and resuming these work units as required, as well as dropping the work units if they were no longer required.
In order to achieve this, Fiber internally references each instance of a React component as a 'fiber' object. The fiber object is a JavaScript object that contains information about a component, its input, and its output. It is analogous to a stack frame, but it also corresponds to an instance of a component.
What this effectively means is that each instance of a component is internally represented in React as a 'fiber', and these fibers have a lot of properties that are used by React to keep track of things like state
and props
.
So what does all this have to do with encapsulating the state
in function components?
Well each component instance is a fiber, and fibers have a lot of internal React-specific properties. In both the class component and the function component, the actual instance of the component is represented as a fiber, that is not accessible outside of React internals.
When we call this.setState
in our class component, rather than immediately merge the partial state into the pre-existing state, Fiber will create an updateAction and enqueue that action. The queue upon which the action is enqueued is specific to each individual fiber, and most actions that are to be performed on a fiber, are enqueued rather than directly run. Enqueueing the actions allows Fiber to schedule and control the execution.
The exact same thing happens when we call useState
hook in a function component, or when we use the setter method returned by the useState
hook. The corresponding action to be performed is enqueued on the fiber that represents this specific instance of the component.
The only difference between the class component and the function component, is how the specific fiber corresponding to a particular component instance is identified. In the class component, the class component instance itself has a property _reactInternalFiber
which directly gives the corresponding fiber. This is set by React when the component is initially constructed.
However, in the function component, there exists no such key. So going back to our example:
function StudentComponent(props){
const [firstName, setFirstName] = useState('Bobby');
const [lastName, setLastName] = useState('Tables');
const [age, setAge] = useState(8);
return <div>{firstName} {lastName} ({age})</div>
};
function TeacherComponent(props){
const [firstName, setFirstName] = useState('John');
const [lastName, setLastName] = useState('Keating');
const [age, setAge] = useState(34);
return <div>{firstName} {lastName} ({age})</div>
};
How does React encapsulate the data between the two component instances? By now, we know that the solution involves maintaining individual fibers for each component instance, and this fiber is set when the component is first constructed. But how does useState
know which component is trying to access the state and return the appropriate state?
Well, the solution that React uses to identify the calling component instances is extremely simple. In React, at any given point in time, there can be only one component that is currently rendering. React also knows exactly when a component render starts, and when it ends. So when a component instance is about to start rendering React sets a flag currentlyRenderingFiber
, pointing to that instance. When the component is done rendering, the flag is nulled.
Since a hook function can only be called from within a function component, and function components are basically just the render methods of the class components, it is guaranteed that whenever a hook is called, we will be in the middle of a render, and currentlyRenderingFiber
will be pointing to the fiber that is being rendered. The state
of the component instance is maintained as a property on this fiber, and that is what useState
uses to get the state for the correct component instance.
Remember, that error we saw earlier when a hook was called from a normal Javascript function?
Uncaught Error: Hooks can only be called inside the body of a function component.
So how did React know this was not called from within a function component? Well, React only sets the currentlyRenderingFiber
, dispatcher
and some other items when the component is rendered as a function component. When you call the hook from a normal javascript object, these items are not set, and that's when React throws the error.
And the magic is dispelled!
So that's basically all there is to all the magic behind React Hooks. Now that we know exactly what's going on behind the scenes, we should be able to better understand hooks, how they work, and why we need to stick to the peculiar rules for writing hooks.
I'd love to hear your opinion on things I've written in this post. Unfortunately, I've not yet found a nice lightweight comments widget to add on to my blog, so until then the only way I can take comments is via email. Do write to me at asleepysamurai at google's mail service.