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:

  • Primitive vs. Reference values
  • Memoization
  • Closures
  • Immutability (Declarative/Functional vs. Imperative programming)

Primitive 🆚 Reference Values

I think this is the most crucial concept to understand. It wasn't apparent for me when I started development with, well, JavaScript.

What is a Primitive Value?

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.

And a Reference Value?

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 and Comparison

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

React uses shallow comparison everywhere with the comparison function Object.is:

  • in hooks dependency list ;
  • by default for React.memo props equality ;
  • for bailing out state updates.

This is important, and that's why I repeat it, but you really need to understand the following snippets:

typescript
const userA = { id: 2, name: "John" };
const userB = { id: 2, name: "John" };
console.log(Object.is(userA, userB));
// >> false
// While
const 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":

typescript
const a = 1;
const b = 1;
console.log(Object.is(a, b));
// >> true
const 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 ===.

Important

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:

  • are the values reference or primitive values?
  • if they are reference values, are they new references or stable references?

It will considerably help you understand what is happening. And if you have any bugs, it will help you solve them.

Memoization

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:

typescript
const 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 cache
console.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).

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.

Implications

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>;
}
i!❌
Can you spot what may not work here?

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>;
}

Some last words: Imperative, Declarative, Functional Paradigms

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.

Use functional programming with React

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:

typescript
function 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.

Conclusion

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 😉.

Formations

React Query1 jour

React Query

Exploitez des données serveur rapidement

En savoir plus

Articles

Go to article

The Children Prop Pattern in React

While it is one of the most underused patterns in React, the Children Prop Pattern is a powerful tool for building reusable components. But it has another aspect that makes it even more interesting to understand and use: re-renders optimization.
ReactPatternsPerformance
November 24, 2022 — 4 min read — 3 rĂ©actions
Go to article

All you need to know about React.useState

A complete guide to React.useState hook mechanisms.
ReactHooksPerformance
August 10, 2022 — 8 min read — 1 rĂ©action