Optimization techniques for UI web applications usually revolve around reducing the number of rendering cycles. Updates of DOM tree are heavyweight operations and unless managed wisely can quickly turn an application from swift and responsive tool to a slow and jittery beast.
Some of biggest selling points of React were Virtual DOM (a lightweight representation component tree and real DOM) and reconciliation algorithm (a way to compare two Virtual DOM trees to determine which parts of an actual DOM need to be updated). These techniques are implemented within React and the details are hidden from a programmer. For most applications, relying on library’s internal optimization is enough, but there are a number of cases when it is important to understand how React’s own rendering is organized and how to use it to optimize an application even further.
A simple example
Let’s start from a simple quiz about rendering of React. Check your knowledge or intuition! Suppose we have a very typical react application that renders a view with a collection in it.
The top-level App is managing all the state. The state is very simple: a collection and ownValue. Collection is passed down to Collection component, that uses Item to render each value. ownValue is only used within an App, so it should not impact rendering of the child components.
I use a RenderCounter component that counts a number of render() invocations, and prints “rendered X times”. To count renders, we use a Ref – this way the update of number of renders does not itself cause a re-render (https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables).
const RenderCounter = () => {
const renderCount = useRef(0);
renderCount.current++;
return <>(rendered { renderCount.current } times);
};
Now Quiz time! Let’s say a user clicks the button that updates a part of a state that will change one item in collection. For example, button “a” will update item “A”, increase its value by 1. How many components will re-render?
It is natural to assume that item with value A will re-render as well as all its ancestors (Collection and App). Alas! React will re-render the whole application! Check out the screenshot.
Before we go scratch our heads and try to figure out what happened here, let’s do one more experiment. What if we update ownValue that is not passed to the Collection? Surely react will not re-render a collection when the value hasn’t changed.
Wrong! It seems that updating any value causes the re-render of the whole component tree. But what about reconciliation algorithms and all those optimizations that React articles talk about?! Let’s find out.
How React rendering really works
In React application there are two phases of rendering. First step is rendering of a new version of Virtual DOM. When the application first displayed on the screen, React calls render() method of each component in a tree and constructs the Virtual DOM representation of a page. Then, a separate library, ReactDOM, transformed Virtual DOM to a real DOM and renders it on a page.
An image below illustrates this process.
This is a process of rendering of an application for the first time, when nothing has been rendered so far. When user clicks a button in the app (while app is on the screen), the process is slightly differentl.
React stores the previous version of a Virtual DOM and compares it to the new version to find the difference between the two (that’s exactly what reconciliation algorithm does). Then react chooses which parts of a real DOM has to be updated:
React treats update of a real DOM as heavyweight, slow operation and tries to optimize it as much as possible. In our example above, React called render() on 5 components, but that resulted in change of text in one DOM element.
React treats the calls to render() methods as lightweight and does not try to optimize them by default. That’s why any change of state of a component causes re-rendering of the whole component tree beneath it. It may still result in only one (or none at all) real-DOM nodes to be updated.
Usually the performance is good enough. Usually… but not always.
When default model becomes a bottleneck
What if you’re building a ticket-booking application and you are rendering a whole stadium with every seat as separate component? Maybe you have a large table with data, each cell represented by a component? Perhaps you are rendering a chart in React, and each data point is represented by a component. In all these cases calling render() for each and every component whether they need it or not is a waste of precious CPU cycles.
React provides tools to implement custom update logic for your components. In class-based components there’s a method shouldComponentUpdate() that checks old props and state with the new props and state and returns boolean value. If the value is false, it signals React that the component (and its children) have not changed and render() phase can safely be skipped.
class MyOptimizedComponent extends Component {
shouldComponentUpdate(nextProps, nextState) {
// implement the custom update check
return nextProps.value !== this.props.value;
}
}
The default implementation of this method always returns true, even when state and props are the same as before.
A typical optimized implementation would do a shallow check of props and state and return true only if there are differences. This implementation is so popular that there’s a special kind of component in React called PureComponent that implements this check out of the box.
class MyOptimizedComponent extends PureComponent {
// only renders when props or state have changed
render() {
…
}
}
Functional alternative
In recent months React community are shying away from object-oriented patterns in React. With an introduction of Hooks, use of class-components are more and more seen as an outdated style of React programming.
Instead of using shouldComponentUpdate(), that implies the use of class, there is a functional alternative, a higher-order component (HOC) called memo(). Don’t confuse memo() with useMemo().
import React, { memo } from 'react';
const MyOptimizedComponent = memo(() => {
return <>...;
});
memo() acts just like PureComponent: shallowly comparing props and skipping re-render as necessary.
The Result
Let’s rewrite our application in a way that and are both wrapped in memo(). Below is an example of such refactoring:
const Item = memo(({ label, value }) => {
return (
Item: {label}={value}
);
});
Try out the new version of the app! You’ll see that changing a state will only render the components that the change belongs to! No more re-render of the world on a slightest change.
A Word of Caution
“Never alter the state or props directly” is a commandment of React that no programmer shall break. In real-world projects however this rule often is overlooked resulting in a code that accidentally mutates old state. This kind of mistakes are often hard to spot and to make things worse, there’s no easy way to enforce immutability.
The default behavior of React is very forgiving. As you saw, it does not rely on checks and comparisons and simply re-renders components with the new state. However, rules change once we implement memoization.
With memos (or old-school PureComponents) it is extremely important to keep state immutable, as it is used in comparison. If this rule is not followed rigorously, it is easy to create bugs that are very hard to find.
Take a look at this example. Given an object like this: { a: 0, b: 0, c: 0 } the correct way to increment property “b” is the following:
setCollection((prev) => ({...prev, b: prev.b + 1}));
For a beginner JavaScript programmer this may seem like a complex construction. You’ll be surprised how often I saw the code like the following in the real-life production projects:
// WRONG, DON’T DO THAT
const newValue = collection;
newValue.b = collection.b + 1;
setCollection(newValue);
In this code, a careless programmer modifies the existing state (collection object) directly. Even though, the reference is copied in the first line, an object is not cloned, it remains the same. As a result, the old state is exactly the same as the new state (the same object in memory) and memo() check will skip the rendering.
Such mistakes are spread wider than most developers think, and before wrapping each component in your application with memo() it is a good idea to perform a thorough code review.
As Donald Knuth once said “Premature optimization is the root of all evil” – it is very much true for React apps.
Summary (TLDR)
Default behavior of React is to re-render all child components if the state or props of a given component changed. Although, it may seem like a waste, it is OK for scenarios when you don’t have hundreds of components on the page. Calling render()is considered light-weight operation (as opposed to actual DOM updates).
To change this behavior and only re-render component once its props or state have changed, wrap it in a standard higher order component - React.memo().
Beware of old state mutation, if the state-changing code is poorly written, components may start skipping updates.
Next Steps
Most of modern React applications are relying on Redux to manage the global application state. react-redux library provides several widely-used hooks like useSelector() to extract the required data from the store.
While this is a very convenient programming technique, programmers tend to unknowingly create new objects with every useSelector() call. This can quickly become an issue causing unnecessary re-renders and degradation of an application performance.
But this use-case is a subject for a next article.