Understanding React Hooks: JavaScript Fundamentals
Hooks are hugely related to more general JavaScript concepts like closures, memoization, and primitive vs. reference values. Correctly understanding these concepts and their role in React Hooks is essential to use them properly.
11 min read - 2107 words
Doing some workshops on React and seeing some people struggling with React Hooks on StackOverflow made me realize that some new React developers don't have a proper understanding of some JavaScript concepts that are crucial to understand React Hooks. This article summarizes the most important JavaScript concepts to play a role in React Hooks. Let's name them:
I think this is the most crucial concept to understand. It wasn't apparent for me when I started development with, well, JavaScript.
A primitive value in JavaScript is a value that is not an object: it has no method or properties.
Seven data types are primitive in JavaScript: null
, undefined
, booleans,
symbols, numbers (including NaN
), bigint
, and strings.
A reference value is an object. In other words, everything that is not a primitive value: objects, arrays, functions, etc.
A reference value references an object in memory.
This is important to know due to the points described below.
Reference values imply that each value references an object in memory.
Two reference values may reference the same object in memory. Two reference values may also reference two different objects that have the same shape and property values but live at distinct places in memory:
React uses shallow comparison everywhere with
the comparison function Object.is
:
React.memo
props equality ;This is important, and that's why I repeat it, but you really need to understand the following snippets:
For primitive values, it is "simpler":
If we sum up, shallow comparison will compare values for primitive values and references for reference values.
So every time you consider some code in React, ask yourself:
It will considerably help you understand what is happening. And if you have any bugs, it will help you solve them.
This technique consists of caching the result of a function based on input parameters. This is useful to avoid recomputing the result of a function if the input parameters are the same.
Let's take a quick example of what would be a memoized function:
In React, this concept is used to cache function references (useCallback
) or
function return values (useMemo
).
useCallback
Consider the following component:
typescript
import React from "react";export function MyForm() {const [showForm, setShowForm] = React.useState(false);const focusElement = (ref) => ref?.focus();return (<><button onClick={() => setShowForm(true)}>Show form</button>{showForm && <input type="text" {...inputProps} ref={focusElement} />}</>);}
We pass the focusElement
function to the ref
prop of the input
element.
A new function is created on each render, assigned to the focusElement
variable and passed to the ref
prop.
This is not a problem if MyForm
component is not re-rendered. But if it is
re-rendered, React will call the focusElement
function again. If the value
assigned to the ref
prop differs from the previous one, React calls this
value again.
To solve this, we use the useCallback
hook:
typescript
import React from "react";export function MyForm() {const [showForm, setShowForm] = React.useState(false);const focusRef = React.useCallback((ref) => ref?.focus(), []);return (<><button onClick={() => setShowForm(true)}>Show form</button>{showForm && <input type="text" {...inputProps} ref={focusRef} />}</>);}
The useCallback
hook returns a memoized version of the function that we pass
as the first argument to the hook. The second argument is the dependency list.
If the dependency list is empty, the function is memoized only once. If the
dependency list is not empty, the function is memoized only if the values of
the dependency list are different from the previous render. This comparison is
made using shallow comparison.
useMemo
The useMemo
hook is similar to useCallback
, but it memoizes the return value
of the function passed as the first argument to the hook.
typescript
import React from "react";export function List({ count }: { count: number }) {const items = React.useMemo(() => new Array(count).fill(0).map((_, i) => i * i),[count]);return (<>{items.map((value) => (<div key={value}>{value}</div>))}</>);}
In this example, the items
array is computed and memoized every time the
count
prop changes. But it is not recomputed if the count
prop is the same
as the previous one (shallow comparison). The return value is memoized.
Closures
Before abording the case of useEffect
hook, I want to talk a bit about
closures.
A closure is a function that remembers its lexical scope, the scope defined at the function creation time. In other words, a closure is a function that has access to the outer function's variables, even after the outer function has returned.
It's like at the function creation, a picture was taken of the references of the variables outside the closure function.
If you have used React, you have used closures:
typescript
function MyComponent() {const [isShown, setIsShown] = React.useState(false);return (<><button onClick={() => setIsShown(true)}>Show</button>{isShown && <div>Content</div>}</>);}
The function assigned to the onClick
property of the button
element is a
closure. It has access to the setIsShown
function and the isShown
variable.
useState
Let's consider the implication for useState
:
typescript
function MyComponent() {const [count, setCount] = React.useState(0);const onClick = () => setCount(count + 1);return (<><button onClick={onClick}>Increment</button>Count: {count}</>);}
The onClick
function is a closure. When the function onClick
is created, it
"retains" the value of the variable count at this moment. On the first render,
its value is 0
. Each time the component re-render, a new closure function is
created in memory, and its reference is assigned to the onClick
variable.
If we click the button twice really fast, the resulting count
variable will
be 1
and not 2
.
Why?
The onClick
function is called on the first click and registers a state update with the new value 1
. But state updates are asynchronous, and React
will re-render the component when it has time. So on the second click, the
onClick
handler is called again. But this closure has access to the count
variable at the time of the closure creation. The count
variable is still
0
, and the state is again updated to 1
.
To solve this, we use functional updates:
typescript
function MyComponent() {const [count, setCount] = React.useState(0);const onClick = () => setCount((count) => count + 1);return (<><button onClick={onClick}>Increment</button>Count: {count}</>);}
The closure does not depend on the count
variable anymore. And React
guarantees that the count
variable passed to the setCount
callback is the
latest value of the count
variable.
useEffect
The useEffect
hook is a bit more complex. It runs the callback function
after the first render and everytime that the dependency list changes.
Let's check this snippet I analyzed during one of my workshops. The aim was to
log the id
passed in props when the component becomes visible in the
viewport, and this only once for the same id
value.
typescript
function Card() {const [wasSent, setWasSent] = React.useState(false);const cardRef = React.useRef<HTMLDivElement>(null);const options = {threshold: 0.9,};React.useEffect(() => {if (!cardRef.current) return;const onVisibilityChange = (entries) => {if (!entries[0].isIntersecting) return;if (wasSent) return;setWasSent(true);console.log(`Card ${id} is visible`);};// The IntersectionObserver is a browser API that triggers a callback// when elements become visible in the viewport.const observer = new IntersectionObserver(onVisibilityChange, options);observer.observe(cardRef.current);return () => observer.disconnect();}, []);return <div ref={cardRef}>Card</div>;}
Let's do a summary. We have two nested closures: the onVisibilityChange
one
and the effect function (the anonymous function).
The effect has an empty dependency list, so it will only run after the first render.
The onVisibilityChange
closure has access to the wasSent
variable and is
created when the effect is run. So here, wasSent
is always false
.
To avoid this, we could pass wasSent
as a dependency to the effect:
typescript
React.useEffect(() => {// ...}, [wasSent]);
But this is not an ideal solution in this case. wasSent
is neither displayed
nor used to display content, we may prefer a reference:
typescript
function Card() {const wasSentRef = React.useRef(false);const cardRef = React.useRef<HTMLDivElement>(null);const options = {threshold: 0.9,};React.useEffect(() => {if (!cardRef.current) return;const onVisibilityChange = (entries) => {if (!entries[0].isIntersecting) return;if (wasSentRef.current) return;wasSentRef.current = true;console.log(`Card ${id} is visible`);};// The IntersectionObserver is a browser API that triggers a callback// when elements become visible in the viewport.const observer = new IntersectionObserver(onVisibilityChange, options);observer.observe(cardRef.current);return () => observer.disconnect();}, []);return <div ref={cardRef}>Card</div>;}
I will briefly sum up the difference between these paradigms (it is a simplified explanation):
with Imperative Programming, you tell the computer what to do and how to do it. You have to write the steps to achieve a result.
with Declarative Programming, you tell the computer what you want but not how to do it.
Functional Programming is a subset of declarative programming that encourages creating new data instead of mutating existing data.
React uses shallow comparison everywhere. So if you mutate an object, you may not observe the behavior you expect on your UI.
Always create new data when you want to mutate objects. This also means not
using in-place methods for arrays (like: .sort
, .reverse
, .push
, .pop
,
.shift
, .unshift
, .splice
, etc.).
Prefer spread operator to create data:
typescript
const newObject = { ...oldObject, newKey: newValue };const newArray = [...oldArray, newValue];
Consider the following example:
If you are skeptical, try this CodeSandbox.
I hope you enjoyed this article! I tried to explain many of the JavaScript concepts necessary to understand hooks in React and how they work. I hope it gave you a better understanding of how the hooks work in React, as much as these concepts have helped my workshop attendees đ.
Exploitez des données serveur rapidement
Maßtrisez le développement d'applications complexes avec React
The Children Prop Pattern in React
All you need to know about React.useState