REACTJS SERIES

Performance Optimization in ReactJs

A guide to ReactJs performance optimization strategies, with scenarios, explained

Chikku George
Globant

--

Credit: Francium Tech

As a developer, the performance of an application is always critical as it might affect the user experience on your website. Multiple studies and research proved that a website’s performance might impact conversion rates and sales. According to Portend study, a website’s conversion rate is five times higher when it loads in one second compared to 10 seconds. As a result, developers should prioritize performance optimization to improve user experience. The application should keep users engaged.

React applications generally deliver a very fast user interface via Virtual DOM. However, when the application expands, performance problems may arise. As part of this article, we will see a few scenarios and discuss how we may use optimization strategies to boost performance.

Optimization Techniques

Below are the optimization techniques which are going to explain in detail.

  1. Code Splitting & Lazy Loading
  2. Debouncing
  3. Throttling
  4. Memo
  5. useCallback
  6. useMemo

Code Splitting & Lazy Loading

In essence, React application merges every file into a single bundle. This bundle is used on a webpage to load a whole application at once. If the application is small-scale, it might not impact the performance. However, the bundle also grows in size as the program does. So you must ensure that the code provided in the bundle does not make it too huge so that the application can load faster.

To avoid large bundles, you can split the bundles through a process called Code Splitting. Bundlers such as Webpack, Rollup, and Browserify provide Code Splitting. Code Splitting allows distinct bundles to be dynamically loaded at runtime. Code Splitting enables lazy loading of the user’s present needs. Using Lazy Loading, you can speed up initial load times by loading parts of your application as needed. In cases where you know the user does not commonly access a specific code, it is ideal to load it when the user asks for it. Both the user experience and the initial loading time are enhanced by this.

Consider a scenario where your React application contains a home and an admin page. The home page will have a link to the admin page. Typically, a React application loads all of its components at once. All the loaded code is visible in the source directory under developer tools. The below image depicts the same.

All components load at once in the source directory

In this scenario, not all users have access to the admin page. Only admin users have access to the admin page. As a result, you don’t need to load the admin component at first. You can divide the code and load the admin page only when the user demands it. Add the below code to lazily load the admin page.

import { lazy } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Home from "./Home";
import "./App.css";

const Admin = lazy(() => import("./Admin"));

function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</BrowserRouter>
);
}

export default App;

When the admin page is lazily loaded, you can see in the source directory that the admin component (admin.js) is not loaded at first. Thus, the initial load time is reduced. The below image depicts the lazy loading of components.

Admin component didn’t load in the source directory

The admin page only loads when you click the Go to Admin link. The below image depicts the same.

Admin component loads only when the user clicks the Go to Admin link

Debouncing

In Javascript, debouncing is a technique used to limit a frequent function call by delaying the execution of the function until a predetermined time. The function will be called only once per use-case to minimize extra CPU cycles and API calls, which improves performance.

A debounce function can be thought of as a higher-order function that accepts a function and a timer as input arguments and outputs another function. Debounce will set aside a scope for the timer variable and plan the timing for when your function will be called. The debounce function is frequently used for text-field autosaves, search box recommendations, and preventing double button clicks.

Imagine a situation where you want to display search query suggestions. When the result is displayed while the user is typing, it can result in a significant number of API calls which is not the recommended approach. The graphic below depicts the search API calls made by a user while typing. As the user types each alphabet, the API is continuously called.

Continuous search API calls while the user types the search query “birds”

The above approach can be optimized by showing the suggestions only after the user has finished typing. A sample implementation of the debounce function in search is shown in the code below. The debounce method is triggered whenever the user types a letter. Each invocation resets the timer or cancels prior plans with handleChange() and reschedules it for a new time of 500 ms in the future. This will continue as long as the user keeps writing in under 500 ms. The handleChange()function will eventually be called because the last schedule won’t be cleared.

import { useState } from "react";

function SearchField() {
const [searchItems, setSearchItems] = useState([]);

function debounce(func, timeout = 500) {
let timer;
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, timeout);
};
}

function handleChange(e) {
const { value } = e.target;
fetch(`https://demo.dataverse.org/api/search?q=${value}`)
.then((res) => res.json())
.then((json) => setSearchItems(json.data.items));
}

const optimisedSearch = debounce(handleChange);
return (
<>
<input
type="text"
placeholder="Enter text here..."
onChange={optimisedSearch}
className="search"
/>
{searchItems?.length > 0 && (
<div className="autocomplete">
{searchItems?.map((item, index) => {
return (
<div key={index} className="autocomplete-items">
<span>{item.name}</span>
</div>
);
})}
</div>
)}
</>
);
}

export default SearchField;

The graphic below displays the optimized API request, which is only made after the user has finished typing. Debouncing significantly reduces the number of API calls.

Optimized search API call by using debouncing

Throttling

In Javascript, throttling limits how frequently a function may be called in a certain amount of time. A function is not allowed to run again for a predetermined amount of time after it has recently been called. Throttle ensures that a function is invoked at a consistent rate. When an application performs a time-consuming computation or an API request in the event handler callback function, throttling the callbacks prevents the app from lagging or overwhelming your server with requests.

Throttling and debouncing are comparable since both are used to enhance a web application’s performance. When you are concerned about the result, use debouncing. For instance, search results once the user has finished typing. On the other hand, throttling controls the rate at which each stage of the operation is carried out. For instance, keeping note of the scrolling offsets that occur as a user scrolls across a web application.

Think of a situation where an event listener tracks the mouse’s movement and performs actions based on location. Find the code for the usual way to handle this situation below.

function handleMouseMove() {
console.log("api call to do some actions");
}
window.addEventListener("mousemove", handleMouseMove);

In this solution, you will have hundreds of requests in a short period as API calls will be triggered on each mouse movement. The below graphic shows the number of API calls in a short duration of mouse movement.

Continuous API calls on mouse movement

In this scenario, you can use the throttle function to limit the amount of API calls. The throttle function takes two inputs: a function to throttle and a delay of the throttling interval in milliseconds, and returns a throttled function. The throttle function is illustrated in the code below.

function throttle(callback, timeout = 1000) {
let shouldWait = false;

return (...args) => {
if (shouldWait) return;

callback(...args);
shouldWait = true;

setTimeout(() => {
shouldWait = false;
}, timeout);
};
}

function handleMouseMove() {
console.log("api call to do some actions");
}
window.addEventListener("mousemove", throttle(handleMouseMove));

As shown in the figure below, employing the throttle function significantly reduces the amount of API calls made for mouse moves.

Reduced number of API calls on mouse move after throttling

React Memo

React memo is used to wrap a component to obtain the memoized version of that component. When the props remain intact, it will skip the component’s re-rendering. In reality, React memo compares the new props with the old ones. It uses the previous render if they are equivalent. It re-renders the component if the props are different. React memo only compares props shallowly, which must be considered when sending methods and objects as props. React re-renders a component whenever its parent re-renders. You may use memoto create a component that does not re-render when its parent does, as long as its new props are the same as the old props. Wrap a component in memo to memoize it, then use the value it produces in place of the original component.

const MemoizedComponent = memo(SomeComponent, arePropsEqual?)

There are two input parameters for React Memo.

  • The first is the memo component. Memoizing does not alter this component, instead returns a new, memoized component.
  • The second parameter is a function that takes two arguments: the component’s previous and new props. If the old and new props are equivalent, it should return true. If not, it ought to return false. You won’t often define this function as React compares each prop by default. When its state or context changes, a component will still re-render even if it has been memoized. Memoization is limited to props supplied to the component from its parent.

Consider the rendering of parent and child components as an example. The child component re-renders whenever the parent does. Even if the child component is unaffected by the count state variable, the example shows that anytime we click the count button in the parent component, the child component likewise re-renders. Find the below code for Parent.js and Child.js respectively.

//Parent.js

import { useState } from "react";
import Child, { MemoizedChild } from "./Child";

function Parent() {
const [count, setCount] = useState(0);
console.log("Parent renders");
return (
<div>
<button onClick={() => setCount((prev) => prev + 1)}>
Count - {count}
</button>
<Child />
</div>
);
}
export default Parent;
//Child.js

function Child() {
console.log("Child renders");
return <div>Child Renders</div>;
}

export default Child;

The image below shows that even though the child component is unaffected by the props, it re-renders with the parent component.

The child component re-renders with the parent component

It is evident that the above re-rendering of the child component is unnecessary, and these superfluous re-renders can impact performance. To improve this rendering behavior, react should only re-render the component when its props change, and this is done using React memo. The needed changes are reflected in the code below.

//Parent.js

import { useState } from "react";
import Child, { MemoizedChild } from "./Child";

function Parent() {
const [count, setCount] = useState(0);
console.log("Parent renders");
return (
<div>
<button onClick={() => setCount((prev) => prev + 1)}>
Count - {count}
</button>
<MemoizedChild />
</div>
);
}
export default Parent;
//Child.js

import { memo } from "react";

function Child() {
console.log("Child renders");
return <div>Child Renders</div>;
}

export const MemoizedChild = memo(Child);
export default Child;

The optimized behavior with memoizing can be seen in the figure below. As long as the props are unchanged, the child component is not re-rendered with the parent component.

React memo prevents unnecessary re-renderings of the child component

useCallback

useCallback is a hook that is used to cache a function definition between re-renders. Utilizing the useCallback hook, we may cache functions that are passed to child components as props to enhance rendering performance.

const cachedFn = useCallback(fn, dependencies)

The hook requires two arguments:

  • The function definition is the first one. During the initial render, React will return this function. If the dependencies have not changed since the last render, React will provide the same function to you again on subsequent renders. In the absence of that, it will return the function you gave during the current render and keep it in case it has to be used again.
  • The second argument is a list of dependencies that includes each value from your component that is utilized inside of your method.

Think of a parent and child component example where the parent has a count state that is updated by an increment button. Additionally, the parent component has a handleChange() function that is supplied as a prop to the child component. The child component is wrapped by memo, therefore it should not be re-rendered if its props do not change. Find the code for the same below.

//Parent.js

import { useState } from "react";
import Child from "./Child";

function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");

const handleChange = (value) => {
setText(value);
};
console.log("Parent Re-renders");
return (
<div className="parent-wrapper">
<Child onChange={handleChange} />
Count: {count}
<br />
<button className="button" onClick={() => setCount((prev) => prev + 1)}>
Increment
</button>
</div>
);
}
export default Parent;
//Child.js

import { memo, useContext } from "react";

function Child({ onChange }) {
console.log(`Child Re-renders`);
return <></>;
}
export default memo(Child);

The child component is re-rendered each time the increment button in the parent component is clicked, as shown in the graphic below. In React, functions are, by default, recreated with each render. Every render will have a different handleChange() function, which will result in the child component being re-rendered.

The child is unnecessarily re-rendering with the parent

Large applications can become sluggish as a result of these unwanted re-renderings. You can encase the handleChange() method with the useCallback hook to prevent this. This will cause handleChange() to return a memoized function, which will be the same as the previous one unless the dependency array changes. Thus, you have control over any unintentional re-renderings of your components. Refer to the updated code for Parent.js below.

import { useCallback, useState } from "react";
import Child from "./Child";

function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");

const handleChange = useCallback((value) => {
setText(value);
}, []);

console.log("Parent re-renders");
return (
<div className="parent-wrapper">
<Child onChange={handleChange} />
Count: {count}
<br />
<button className="button" onClick={() => setCount((prev) => prev + 1)}>
Increment
</button>
</div>
);
}
export default Parent;

The graphic below demonstrates how the useCallback hook is properly used to re-render the child component. You can see that the child is not re-rendered along with the parent.

The child is not re-rendering after using useCallback

useMemo

The useMemo React hook is used to cache a calculation’s outcome between re-renders.

const cachedValue = useMemo(calculateValue, dependencies)

It requires two parameters:

  • The first parameter is the function computing the value you want to cache. It should be a pure function that doesn’t accept any inputs and returns a value of any type. React will call this function during the first render. If the dependencies have not changed since the last render, React will return the same result. If not, it will call this function, return its outcome, and store it for later use.
  • The second parameter is a list of dependencies that includes each value from each component used in your calculation.

Consider having two buttons in the application for adding and subtracting.

You are also required to show the multiplying result when you press the add button. In real-world examples, any logic might take a considerable amount of time to execute it. For example, fetching a huge number of items and sorting and filtering them. To simulate this scenario, you need to induce some slowness in the application by adding a while loop to the multiply function that will iterate for a long time. Refer to the code below for the same.

import { useState } from "react";

function App() {
const [add, setAdd] = useState(0);
const [subtract, setSubtract] = useState(0);

const multiply = () => {
let i = 0;
while (i < 2000000000) i++;
console.log("Multiply");
return add * 5;
}
return (
<div>
<button onClick={() => setAdd(add + 1)}>Add: {add}</button>
<span>Result: {multiply()}</span>
<br />
<button onClick={() => setSubtract(subtract - 1)}>
Subtract: {subtract}
</button>
</div>
);
}

The graphic below shows the slowness before the result is loaded when you click the add button. Even when the add button produces the multiplying result, clicking the subtract button exhibits the same slowness. This is because the multiply function is re-rendered each time the state changes.

Sluggishness and component’s unnecessary re-renderings

You must prevent unnecessary recalculations of certain values, particularly those that take a lengthy time to compute. In this case, whenever the subtract states are changed, you don’t need to calculate the multiplying result. Hence, the multiply function can be wrapped in a useMemo hook that will only recalculate the value when the dependency array changes. This optimization prevents expensive calculations on every render. The updated code is given below.

import { useMemo, useState } from "react";

function App() {
const [add, setAdd] = useState(0);
const [subtract, setSubtract] = useState(0);

const multiply = useMemo(() => {
let i = 0;
while (i < 2000000000) i++;
console.log("Multiply");
return add * 5;
}, [add]);

return (
<div>
<button onClick={() => setAdd(add + 1)}>Add: {add}</button>
<span>Result: {multiply}</span>
<br />
<button onClick={() => setSubtract(subtract - 1)}>
Subtract: {subtract}
</button>
</div>
);
}

You can notice in the graphic below that after adding useMemo, the subtract operation removes the sluggishness while the add operation has the expected delay.

Sluggishness of subtract operation is prevented using useMemo

Summary

The performance of an application is always an important aspect of the application. This article has merely discussed a few scenarios in which performance issues could occur and various approaches for optimizing them in React applications. Always optimize your application’s performance to provide a nice user experience.

--

--

Chikku George
Globant

Software Engineer | ReactJS | NodeJS | Blockchain Enthusiast