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:
On this schema, userA and userB reference two different objects in memory.
These values are deeply equal but not shallowly equal: they do not reference
the same object in memory.
On the other hand, as userC is assigned to userD. userD now references the
same object as userC. userD and userC are equal shallowly and deeply.
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:
typescriptconst userA = { id: 2, name: "John" };const userB = { id: 2, name: "John" };console.log(Object.is(userA, userB));// >> false// Whileconst userC = { id: 2, name: "John" };const userD = userC;console.log(Object.is(userC, userD));// >> true
In above snippet, userA and userB are not shallowly equal: they do not
reference the same object. userC and userD are shallowly equal: they
reference the same object.
For primitive values, it is "simpler":
typescriptconst a = 1;const b = 1;console.log(Object.is(a, b));// >> trueconst c = "foo";const d = "foo";console.log(Object.is(c, d));// >> true
Primitive values that have the same values are shallowly equal. Check
Object.is
reference
for details and comparison with ===.
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:
typescriptconst square = (n) => n * n;function memoize(fn) {const cache = {};return (n) => {if (cache[n]) return cache[n];const result = fn(n);cache[n] = result;return result;};}const memoizedSquare = memoize(square);console.log(memoizedSquare(2));// >> 4 // compute and cacheconsole.log(memoizedSquare(2));// >> 4 // return cached value
In this example, memoize is a helper function that cache the result of
square based on the input parameter n.
In React, this concept is used to cache function references (useCallback) or
function return values (useMemo).
useCallbackConsider the following component:
typescriptimport 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:
typescriptimport 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.
useMemoThe useMemo hook is similar to useCallback, but it memoizes the return value
of the function passed as the first argument to the hook.
typescriptimport 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.
ClosuresBefore 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:
typescriptfunction 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.
useStateLet's consider the implication for useState:
typescriptfunction 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:
typescriptfunction 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.
useEffectThe 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.
typescriptfunction 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:
typescriptReact.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:
typescriptfunction 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:
typescriptconst newObject = { ...oldObject, newKey: newValue };const newArray = [...oldArray, newValue];
Consider the following example:
typescriptfunction List() {const [items, setItems] = React.useState([]);const addItem = () => {const newItems = items;newItems.push("new item");setItems(newItems);};return (<><button onClick={addItem}>Add item</button><ul>{items.map((item) => (<li key={item}>{item}</li>))}</ul></>);}
This example will not work as expected. The items array is mutated in place
by the addItem function. So the items array passed to setItems is the
same reference as the items array before the mutation. React will bail out
the state updates and not re-render our component.
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