Try   HackMD

React Hooks and the Functional Component Lifecycle (WIP)

This deep-dive assumes you have a decent understanding of JavaScript fundamentals, including the differences between when synchronous and asynchronous code is executed, short-circuit expressions and short-circuit execution, and IIFEs.

The basics

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:

  • Mount, the first invocation, when the component first renders to the page
  • Update, often referred to as Rerender, when the component re-invokes as its internal state changes
  • Unmount, the act of the component removing itself from the DOM

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.

import { useState } from 'react'; const Counter = () => { const [count, setCount] = useState(0); const countUp = () => { setCount((prevCount) => prevCount + 1); }; const countDown = () => { setCount((prevCount) => prevCount - 1); }; return ( <div id="counter-container"> <button className="counter-button" onClick={countUp}> Count Up </button> {count} <button className="counter-button" onClick={countDown}> Count Down </button> </div> ); };

Live codepen example

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:

  const [
    count, // getter
    setCount // setter
  ] = useState(
    0 // initial value
  )

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:

let componentIsMounted = false; let componentShouldUpdate = false; let valueNextRender; const watchComponentState = () => { while (componentIsMounted) { if (componentShouldUpdate) { Counter(); } componentShouldUpdate = false; } componentShouldUpdate = false; valueNextRender = undefined; } const useState = initialValue => { const updateStateValue = stateOrFunction => { (async () => { if (typeof stateOrFunction === 'function') { valueNextRender = stateOrFunction(valueNextRender); } else valueNextRender = stateOrFunction; })(); }; if (valueNextRender === undefined) { valueNextRender = initialValue; } return [valueNextRender, updateStateValue] }; export default function Counter () { componentIsMounted = true; const [count, setCount] = useState(0); const countUp = () => { setCount(prevCount => prevCount + 1); }; const countDown = () => { setCount(prevCount => prevCount - 1); }; watchComponentState(); return ( <div> <button onClick={countDown}> Count Down </button> {count} <button onClick={countUp}> Count Up </button> </div> ); }

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.

import { useState, useEffect } from 'react'; export default function Counter () { const [count, setCount] = useState(0); useEffect(() => { const interval = setInterval(() => { setCount(prevCount => prevCount + 1); }, 1000); return () => { clearInterval(interval); }; }, []); return ( <div> <h1>The current count is {count}</h1> </div> ); }

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

() => {
  const interval = setInterval(() => {
    setCount(prevCount => prevCount + 1);
  }, 1000);

  return () => {
    clearInterval(interval);
  };
}

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:

let componentIsMounted = false; let componentShouldUpdate = false; let effectShouldRun = false; let previousDependencies; let cleanup; const watchComponentState = () => { while (componentIsMounted) { if (componentShouldUpdate) { Counter(); } componentShouldUpdate = false; } componentShouldUpdate = false; if (typeof cleanup === 'function') { cleanup(); } } const useEffect = (effectCallback, dependencyArray) => { (async mounted => { if (dependencyArray === undefined) { if (typeof cleanup === 'function') { cleanup(); } cleanup = effectCallback(); } else if (Array.isArray(dependencyArray) && dependencyArray.length > 0) { effectShouldRun = dependencyArray.some((ele, idx) => { return !Array.isArray(previousDependencies)) || previousDependencies[idx] !== dependencyArray[idx]; }); previousDependencies = [ ...dependencyArray ]; if (effectShouldRun) { if (typeof cleanup === 'function') { cleanup(); } cleanup = effectCallback(); } } else if (!mounted) { cleanup = effectCallback(); } effectShouldRun = false; })(componentIsMounted) } export default function Counter () { const [count, setCount] = useState(0); useEffect(() => { const interval = setInterval(() => { setCount(prevCount => prevCount + 1); }, 1000); return () => { clearInterval(interval); }; }, []); componentIsMounted = true; return ( <div> <h1>The current count is {count}</h1> </div> ); }

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.