Maximizing Component Efficiency in React: Best Practices for Performance
The React framework is a powerful tool, providing developers with the ability to prevent unnecessary changes in the DOM. It does this by rendering the entire components tree to build the virtual DOM and understand if anything has changed. This process allows React to identify which DOM elements need to be updated and which can remain the same.
However, React can do even better than this. It provides developers with the ability to optimize the rendering of the components tree, so that the virtual DOM only needs to be updated when absolutely necessary. This can be achieved by leveraging memoization of components, which can be used to store the rendered trees, ensuring that they are only calculated when absolutely necessary.
By leveraging React's powerful features, developers can ensure that their applications are as efficient as possible, preventing unnecessary updates to the DOM and ensuring that their applications are running as smoothly as possible.
function Parent () {
const [counter, setCounter] = React.useState(0);
return (
<>
<button
children={counter}
onClick={() => setCounter((c) => c + 1)}
/>
<Child />
</>
);
}
function Child() {
return "Child";
}
In this example we have a single button that changes the component
state on click, and one component next to it called Child. The
change of the state in the Parent component will trigger DOM change.
React will build the complete Parent virtual DOM tree, compare it
to the older tree to find what has changed and update the text inside of the
button. So why do we have Child here?
Child component is obviously has no need to be called ever again
but React will do it on every Parent render. You can easily see
it by adding console.log() inside of the function:
function Child() {
console.log("Rendering Child");
return "Child";
}
Now you can see that with every click on the button, the Child component
is being rendered. Whenever Parent component will be rendering it
will cause the rendering of all components used inside of it. And while in this
example the Child component won't cause any problem cause it does
not perform any heavy operations, in real world applications it can be the large
list of data, deeply nested tree of elements, or even the whole application sitting
next to the theme toggle button.
There are 2 strategies you can use to prevent unnecessary Child
renders.
State Isolation
The state change is only triggering the render of the component where the
state is declared and its children. In this example it is the Parent component with Child inside. What we could do to prevent the Child component to render multiple times is to prevent the Parent component
from rendering. We can achieve it by moving the counter state one level down
isolating its state from the parent:
function Parent () {
return (
<>
<Counter />
<Child />
</>
);
}
function Child() {
console.log("Rendering Child");
return "Child";
}
function Counter() {
const [counter, setCounter] = React.useState(0);
return (
<button
children={counter}
onClick={() => setCounter((c) => c + 1)}
/>
);
}
If you would run this code you would see that the Child is only
printing "Rendering Child" on initialization but never more
even when counter is updated. Well done! But what if we cannot change layout
or our child components depending on that counter state?
Component Memoization
Let's update our code first:
function Parent() {
const [counter, setCounter] = React.useState(0);
return (
<>
<button
children={counter}
onClick={() => setCounter((c) => c + 1)}
/>
<Child enough={counter > 3} />
</>
);
}
function Child({ enough }) {
console.log("Render Child");
return enough ? "Done!" : "Click the button";
}
Now Child is dependant on the counter state and so
we cannot isolate it. Yet, we don't need to render Child every time
but only when counter is greater than 3. We can achieve it by saying
React to check properties of the component and only render it when they are changed.
We can do it by using React.memo():
const Child = React.memo(function Child({ enough }) {
console.log("Render Child");
return enough ? "Done!" : "Click the button";
});
This was even easier than isolating state in a separate component and the result is amazing. We can drop this component anywhere and be sure that it won't be updated until its properties are not changed. I only wonder why this is not the default behaviour of React?
Memoization Caveats
There are some caveats you should be aware of when using memoization. First
of all, memoization is only working with functional components. If you are
using class components you should use React.PureComponent instead.
It is working in the same way as React.memo() but for class components.
Another thing to be aware of is that memoization is only working with
properties of the component. If you are using useState() or
useContext() hooks inside of the component, it will be re-rendered
every time the state is changed.
And the last thing to be aware of is that memoization is only working with shallow comparison of properties. If you are passing an object, an array, or a function directly into the property, it will be compared by reference and so the component will be updated every time:
function Parent () {
const [counter, setCounter] = React.useState(0);
const api = React.useContext(ApiContext);
return (
<>
<button
children={counter}
onClick={() => setCounter((c) => c + 1)}
/>
<Popover settings={{ position: "bottom" }} />
<Actions features={['close', 'save']} />
<Form onSubmit={(data) => api.save(data)} />
</>
);
}
This can be easily fixed using React.useMemo() and React.useCallback().
Properties Memoization
React.useMemo() calls the given function, stores its result, and
returns it on every render until the dependency list is not changed. When the
list is changed, the function will be called again and the new result will replace
the old one.
This way we can store non-primitive types inside of the component keeping their reference the same:
function Parent () {
const [counter, setCounter] = React.useState(0);
const api = React.useContext(ApiContext);
const isCounterExceeded = counter > 3;
const settings = React.useMemo(
() => ({
position: isCounterExceeded? "top" : "bottom"
}),
[isCounterExceeded]
);
const features = React.useMemo(
() => ['close', 'save'],
[]
);
const onSubmit = React.useMemo(
() => (data) => api.save(data),
[api]
);
return (
<>
<button
children={counter}
onClick={() => setCounter((c) => c + 1)}
/>
<Popover settings={settings} />
<Actions features={features} />
<Form onSubmit={onSubmit} />
</>
);
}
To memoize functions React has a shorthand utility React.useCallback():
const onSubmit = React.useCallback(
(data) => api.save(data),
[api]
);
Extracting Properties
Some values does not need to be updated and may not depend on any values inside of the component. In such cases it does make sense to extract them to put outside of the component not to ever force JavaScript to build those values again:
const features = ['close', 'save'];
function Parent () {
return (
<>
<Actions features={features} />
</>
);
}
Sometimes, this may also be applied to functions:
import api from './api';
const onSubmit = (data) => api.save(data);
function Parent () {
return (
<>
<Form onSubmit={onSubmit} />
</>
);
}
And other times the function is dependant on other properties but it doesn't make sense to refresh the component every time because those dependencies are only matter when the function is called.
Referencing Dependencies
function Parent () {
const [counter, setCounter] = React.useState(0);
const api = React.useContext(ApiContext);
const onSubmit = React.useCallback(
() => api.save(`Clicked ${counter} time(s)`),
[api, counter]
);
return (
<>
<button
children={counter}
onClick={() => setCounter((c) => c + 1)}
/>
<Form onSubmit={onSubmit} />
</>
);
}
In the example above the onSubmit() function is dependant on the
counter and as so it is restored every time the counter is changing. The update of onSubmit() will cause <Form /> to render even if it is memoized. If you think about this example for another
second you may find out that this update is redundant.
The onSubmit() implementation doesn’t really matter until it is
called. In other words, the onSubmit() code is only worried about
the actual, very last component state. To give it to the callback without updating
it every time dependencies are changing we can make use of react references.
We can reference either the whole function or its dependencies. Here is the complete example with referenced dependencies:
function Parent () {
const [counter, setCounter] = React.useState(0);
// We create a reference for counter state
// because we want to use it in the callback
// that will be passed as a property to child
// component but we don't want the callback
// to be changed every time the state does
const counterRef = React.useRef(counter);
// Right in the component, on every its render,
// we update the value of the reference with
// the latest state value
counterRef.current = counter;
const onSubmit = React.useCallback(
// Use the counter from the reference
// instead of the state to ensure to have
// the latest value
() => api.save(`Clicked ${counterRef.current} time(s)`),
// Refrence objects references are never
// changed, but the linter may ask you to
// list all variables you are using in the
// callback
[counterRef]
);
return (
<>
<button
children={counter}
onClick={() => setCounter((c) => c + 1)}
/>
<Form onSubmit={onSubmit} />
</>
);
}
In the above component the onSubmit() reference will never change,
so <Form /> will always receive the same function and if the
Form component is memoized the Parent will never ask
it to render again.
We can simplify the code by creating a custom hook to constantly update the reference:
function useLastRef<T>(value: T) {
const ref = useRef<T>(value);
ref.current = value;
return ref;
}
Or install use-last-ref if you prefer packages. Then the code would look like this:
const [counter, setCounter] = React.useState(0);
const counterRef = useLastRef(counter);
const onSubmit = React.useCallback(
() => api.save(`Clicked ${counterRef.current} time(s)`),
[counterRef]
);
Note that in cases when values are only used in callback functions, you may not need state at all. Create a reference and update data directly in it:
function Parent() {
const counterRef = useRef(0);
const onSubmit = React.useCallback(
() => api.save(`Saved ${++counterRef.current} time(s)`),
[counterRef]
);
return <Form onSubmit={onSubmit} />;
}
Referencing Callback
In many cases it would make more sense to reference the whole callback instead of wrapping each dependency value in references. To accomplish it you save your function to the ref object on each render and create memoized callback that will call that function from the reference:
function Parent({ customer }) {
const onSubmitRef = useLastRef((newRecord) => {
api.createRecord(customer.id, newRecord);
});
const handleSubmit = React.useCallback(
(data) => onSaveRef.current(data),
[onSaveRef]
);
return <RecordForm onSubmit={handleSubmit} />;
}
Referencing function may reduce the amount of code a lot and it has no disadvantages compare to referencing dependencies. You can also shorten the code by using use-ref-callback:
import useRefCallback from 'use-ref-callback';
function Parent({ customer }) {
// The handleSubmit is a never changing function
// yet always updating the reference to the passed function
// and accessing the latest data of the component scope
const handleSubmit = useRefCallback((newRecord) => {
api.createRecord(customer.id, newRecord);
});
return <RecordForm onSubmit={handleSubmit} />;
}
This technique is very handful when passed callbacks may be not memoized. You simply wrap each of them and pass further:
function Parent({ onOne, onAnother }) {
const handleOne = useRefCallback(onOne);
const handleAnother = useRefCallback(onAnother);
return <MemoizedChild onOne={handleOne} onAnother={handleAnother} />;
}
Conclusions
React doesn’t do its best on optimization of calculations, probably due to
the backward compatibility issue. But it does provide us will all the
necessary tools. By using React.memo() in pair with React.useMemo(),
React.useCallback(), React.useRef() hooks, and a couple
of techniques you can significantly reduce the amount of calculations that does
happen when component are rendering. And with its enormous benefit it is still
an easy change in your code. So let’s do it!