The first thing you need to understand about functional components is that they are functions. For all intents and purposes, they execute every line of code, from top to bottom, on every render. That's why if you put a console.log()
in the middle of a component body, outside of a hook, it seems to run almost constantly, every time any part of that component changes state - because it does. Every variable gets re-declared, every function gets re-invoked, every time. And yes, this means that on every render, every hook that you've invoked gets invoked again.
The magic of hooks is that they don't execute the exact same functionality on every invocation - instead, their function bodies are written such that they conditionally execute based on the context in which they were invoked, and the state of the component and of your app at large.
A functional component has three basic phases that it goes through:
If you can understand these three phases of component rendering, when they happen, and what causes them, you will completely de-mystify the "The Great Event Listener in the Sky" that is the React engine.
useState
Let's start with our bread-and-butter hook, useState
. If you've read any documentation or followed any React tutorials, you're likely already familiar with this hook, as it's the very first one you should be taught, day 1 React. In my first introduction to useState
, I shown how to make a simple, incrementing and decrementing counter. Let's look at some very basic code that implements that here.
This code generates a div
with two buttons, and a number between them. Clicking Count Up
increments the value by 1, while clicking Count Down
decrements.
Super simple. But what's actually happening here?
Generally speaking, a useState
implementation has 3 parts - the two halves of its return value, and the value with which its invoked. From left to right in the code above, I'll be referring to these as the getter, setter, and initial value from here on out.
Here's a minor rewrite to clarify this a bit:
The initial value is pretty straightforward - this is the value that count
takes on Mount, when the component first renders.
The setter is a synchronous function which triggers an asynchronous update of the getter value.
Read that again.
The setter itself is a synchronous function - you cannot await
it, but the action it triggers, updating the getter value, is asynchronous, meaning that it takes place after all the synchronous code in the function has finished executing.
Notice that our functional component is also synchronous (as it should be) - this means that, from the time useState
is first invoked, all the way to the bottom of the return value, the getter value remains unchanged. It is only during the Update portion of the component lifecycle, when the component is invoked again, and subsequently the line const [count, setCount] = useState(0)
is run again, declaring a brand new variable named count
that's set to the return value of useState(0)
, that the value updates.
This should be somewhat intuitive to you, since you should already know how variable declaration keywords work - if you can declare count
using const
, and everything still works fine when you change the value, then it's impossible for the value change to happen within the same evaluation - items declared with const
always retain their value absolutely within the scope in which they're declared. They may mutate, but they will always be ===
equal to any and all other instances of themselves within scope. Thus, the change to the value of count
must not be happening instantly at the time setCount
gets called, but instead when count
first gets decalred.
"But wait," I hear you say, "I thought useState(0)
set count
to 0, how does invoking it again give count
a different value?"
Remember when I said earlier that hooks don't always do the same thing? Their return values will always be the same shape, in this case useState
will always return an array of length 2 whose values are of the same datatype, but they won't always do the same thing, or return the same actual values.
Let's imagine we have the ability to "zoom out" on the React engine for a minute. If we did, our component would look something like this:
I wouldn't suggest copying this particular block of code, as that while
loop up top will eat your CPU. In reality, React uses a complicated system of event listeners that can be directly triggered by certain hooks, but we can imagine that this code works the same way. Let's take it from the top.
We start by declaring two values to monitor where the component is in its lifecycle - componentIsMounted
and componentShouldUpdate
. These are exactly what they sound like - whether the functional component has been invoked at least one time by an outside source, i.e. the parent component, and whether any of its interior components have changed state, indicating that the component should re-render.
Our simulated React engine, the infinite while
loop, doesn't do anything until the component has mounted at least once, at which point any time componentShouldUpdate
becomes true, our Counter
function will be re-invoked, re-running all the code inside of it from top to bottom.
The third value, valueNextRender
, we declare, but don't initialize. undefined
is illegal in React, and that's how you should view it; it's a value you should never encounter unless absolutely necessary. If you must set some value empty, set it to null
. null
is a value you are allowed to return from a render function, undefined
is not. If you've ever seen an error for nothing was returned from render
it's because you returned undefined
instead of null
; the rest of that error informs you that if you wanted to not render anything, intentionally, to return null
instead. This is a bit of functional programming praxis React enforces; null
is a return value, undefined
is an accident. Accidents are not allowed.
So, we are assuming here that we will never, ever, ever ever ever call our useState
setter on undefined
and are reserving that as the one JavaScript value that means "valueNextRender
has never been assigned before, and as such should now be set to our initialValue
because this is our first render".
Lastly, I've wrapped the body of the updateStateValue
function in an async IIFE, simply to illustrate that it will be deferred to the next render cycle, occurring after all references to valueNextRender
aka the setter within the synchronous functional component body.
This is how useState
works; the first time the component is invoked, it returns whatever value was passed to it as a parameter. On all subsequent invocations, until the component has unmounted, it returns a captured value that only updates after the setter is called.
useEffect
This is my personal favorite hook. useEffect
is my best friend, and with a bit of understanding, it can be yours, too. Let's create another counter, this time using useEffect
in conjunction with useState
to create a counter which automatically increments every second.
Somewhat similarly to useState
, useEffect
has three parts. They are, from top to bottom, the effect callback, the effect cleanup, and the dependency array. The one you're most familiar with is the effect callback - this is the function you pass as the first argument to the useEffect
call. Let's take this function apart
We start by declaring our interval, and capturing its return value - an identifier that points to it uniquely - in a variable clearly labeled interval
. This capture is so that we can destroy this interval when it's no longer needed, preventing memory leaks and unnecessary or uncontrolled updates to our application state.
We perform this destruction within the effect cleanup, the return value of the effect callback. The return value of the effect callback must be a function. Not a primitive, not an object, and most certainly not a promise.
But, how exactly does the effect cleanup work, and when does it run? Let's imagine once more that we have the ability to "zoom out" on the React engine, and get a look a what the definition and storage for useEffect
might look like:
First, another similarity to useState - the body of useEffect
is asynchronous, but you cannot await it. You must simply be aware of the fact that, as of React 17, useEffect
will not actually invoke your effect callback until after the functional component has finished mounting to the virtual DOM. This is for your benefit - it means that, for example, if you were to attempt to grab DOM nodes with either querySelector
/getElementById
or useRef
, you can trust that they will exist reliably by the time the effect callback runs. Conversely, if you were to write code that attempted to interact with the nodes rendered in your return value simply out in the functional component body, outside of a hook, the behavior would not be what you expected. In regular, synchronous code, that return
statement which actually creates the nodes, would occur only after you had tried to interact with them.
What you should take away from this is that the body of useEffect
is asynchronous, but not in a way that would allow you to await
it, and the callback which you pass to useEffect must itself be synchronous so that its return value, the effect cleanup, can be accessed.
If our dependencyArray
argument is not passed in, and consequently dependencyArray
is undefined
, then we know we've invoked useEffect
without any dependencies to monitor, and it should run the effect callback on every single render, no matter what. This is dangerous, potentially expensive, and almost always bad practice - don't do it. You should always give useEffect
a dependency array to ensure it only runs when you want it to.