Maximizing React Performance: Tips and Tricks for Lightning-fast Apps
OVERVIEW
Application optimization is very important for any developer looking to build a great user experience to keep the user engaged in the app. Research shows that users' flow is interrupted if pages load more than one second. It's therefore crucial for every developer to create a highly performant app.
React almost guarantee a fast UI by default due to its internal performance mechanisms. However, as React app scales, we might experience performance issues or lags. In this article, we will discuss the mindset to hold during optimization and different techniques and methods for maximizing our React Performance.
OPTIMISATION MINDSET
Before we start throwing in optimization techniques, we should measure the application's performance to establish that we have a performance issue. Premature optimization might create more bad than good, as we might cause more performance issues while trying to optimize. This brings up the importance of measurement during optimization.
However, there are some coding patterns and techniques we can employ to ensure a naturally performant app.
So the mindset is to apply some coding patterns during development, measure to ensure there's a performance issue and use the techniques in this article while putting their tradeoffs at heart.
Measuring Performance Using Profilers
Using the Profiler within React DevTools, we can assess the performance of our applications by collecting data each time the app renders.
This tool captures the duration of component rendering, the reason behind it, and additional details. With this information, we can examine the relevant component and implement suitable optimizations. You can learn more about profiling in this article.
TECHNIQUES DURING DEVELOPMENT
Pushing State Down
In React, a state change in the parent component will cause a rerendering in all child components even when the children are not directly affected by the state change. In the code below, whenever the name
state changes, the Child
component is rendered even when the state does not affect it.
export default function Parent() {
const [name, setName] = useState("");
return (
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<h3>Full Name: {name}</h3>
<Child/>
</div>
);
}
function Child() {
console.log("child component is rendering");
return <div>This is the child component.</div>;
};
To avoid unnecessary rerendering, we can separate the part of the code that are affected by the state changes into a separate component NameInput
thereby pushing the state down to the new component.
export default function Parent() {
return (
<div>
<NameInput/>
<Child/>
</div>
);
}
function NameInput() {
const [name, setName] = useState("");
return (
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<h3>Full Name: {name}</h3>
</div>
);
}
function Child() {
console.log("child component is rendering");
return <div>This is child component.</div>;
}
This guarantees that solely the component concerned with the state is re-rendered. In our code, only the input field is interested in the state. Therefore, we relocated the state and input to a separate NameInput
component, which is now a sibling of the Child
component.
Consequently, whenever the state changes, only the NameInput
component rerenders. The Child
component, on the other hand, no longer re-renderers with each keystroke. Also, we can apply this technique right during our development without any serious tradeoffs.
Immutable Data Structure
In React, the state
should be treated as immutable, and we should never directly mutate it. Rather than directly making changes to an object that contains complex data, we can make a copy of the object that has been updated with the changes. This way, we can easily compare the references of the original object and the new one to determine the changes that will trigger a UI update.
export default function App() {
const [profile, setProfile] = useState({
name: "Julius Berge",
age: 28
});
const updateProfile = () => {
profile.name = 'Anderson Leo'
};
return (
<div className="App">
<h2>Update the profile</h2>
<p>profile.name</p>
<p>profile.age</p>
<button onClick={updateProfile}>Update</button>
</div>
);
}
In the code above, we’re trying to directly update the profile
state in the updateProfile
function. This will cause performance issues because React cannot track the change to update the UI accordingly. This can be fixed by treating the profile
state as an immutable data structure instead of trying to mutate it directly:
const updateProfile = () => {
const newProfile = { ...profile };
newProfile.name = "Anderson Leo";
setProfile(newProfile);
};
React can now easily track any state changes that occur and properly update the UI accordingly. We can use other third-party libraries to achieve the same goal like Immer and Immutable.js.
Using Keys in Listing Array
Whenever we render a list of items by mapping through an array, it's very important to give each item of the array a unique key
attribute. This helps React identify which items have changed, added, or removed and make the correct updates to the DOM tree.
The best way to pick a key is to use a string that uniquely identifies a list item among its siblings, like database keys/IDs if our data is coming from a database.
const commentItems = comments.map((comment) =>
<li key={comment.id}>
{comment.text}
</li>
);
If our data is generated and persisted locally, we can use crypto.randomUUID()
, an incrementing counter or a package like uuid when creating items.
Also, we should resist the temptation to use the item’s index in the array as its key, this can lead to subtle and confusing bugs.
TECHNIQUES AFTER DEVELOPMENT
Code Splitting
A great optimization technique to apply when we have performance issues is code splitting. When a React application runs in a browser, the entire application code is loaded and served to the users at once in a bundle file. This file is generated by merging all the code files of the application.
Bundling helps reduces the number of HTTP requests a page can handle, but as the application grows, the code files increase which in turn increases the size of the bundle file. This large bundle size at some point will slow down the initial page load.
Code-splitting allows us to split a large bundle file into multiple chunks using dynamic import()
followed by lazy loading these chunks on-demand using the React.lazy
. This will significantly optimize the performance of a large React application.
To implement code-splitting, we change a normal React import like this:
import Dashboard from "./components/Dashboard";
import Analytics from "./components/Analytics";
And then into something like this:
const Dashboard = React.lazy(() => import("./components/Dashboard"));
const Analytics = React.lazy(() => import("./components/Analytics"));
This tells React to load each component dynamically. So, when a user clicks a link to the dashboard
page, for instance, React only downloads the file for the requested page instead of loading a large bundle file for the whole application.
After the import, we have to render the lazy components inside a Suspense
component. The Suspense
allows us to display a loading indicator as a fallback while React waits for the lazy component to render in the UI.
<Suspense fallback={<p>Loading page...</p>}>
<Route path="/dashboard">
<Dashboard />
</Route>
<Route path="/analytics">
<Analytics />
</Route>
</Suspense>
The tradeoff here is that dynamically imported pages take time to load because it's only loaded when the user navigates to the page. However, based on business metrics, some pages might need to be pre-fetched from the first load as you will want users to access them as fast as possible. So we should not apply code splitting to every page in our application, but rather base its usage on business metrics(i.e. pages that need to be bundled from the initial load and ones that can be deferred to when they are needed).
Memoization using React.memo, useMemo and useCallback
Memoization is a technique for speeding up computer programs by caching the results of expensive function calls and returning the cached result when the same inputs occur again.
So, if a child component receives a prop, a memoized component shallowly compares the prop by default and skips re-rendering the child component if the prop hasn’t changed
import { useState } from "react";
export default function App() {
const [value, setValue] = useState("");
const [count, setCount] = useState(0);
return (
<div>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button onClick={() => setCount(count + 1)}>Increment counter</button>
<h3>Input text: {value}</h3>
<h3>Count: {count}</h3>
<hr />
<Child count={count} />
</div>
);
}
function Child({ count }) {
console.log("child component is rendering");
return (
<div>
<h4>Count: {count}</h4>
<h2>This is a child component.</h2>
</div>
);
}
When the input field changes, both the App
component and Child
re-render. What we want is the Child
should only re-render when clicking the count button because it must update the UI. So, we can memoize the Child
to optimize our app’s performance.
Using React.memo()
React.memo
is a higher-order component used to wrap a purely functional component to prevent re-rendering if the props received in that component never change:
import React from "react";
const Child = React.memo(function Child({ count }) {
console.log("child component is rendering");
return (
<div>
<h2>This is a child component.</h2>
<h4>Count: {count}</h4>
</div>
);
});
If the count prop doesn't change, React will skip rendering Child
and reuse the previously rendered result. Hence improving React’s performance.
React.memo()
shines the best when we pass down primitive values, such as a number in our example. Primitive values remain referentially equal and return true if values never change.
In contrast, non-primitive values like object
which include arrays
and functions
, always return false between re-renders because they point to different spaces in memory. When we passed any of these non-primitive values as a prop, the memoized component will always trigger a re-render.
In this particular case, we are passing a function to the child component:
import React, { useState } from "react";
export default function App() {
// ...
const incrementCount = () => setCount(count + 1);
return (
<div>
{/* ... */}
<Child count={count} onClick={incrementCount} />
</div>
);
}
const Child = React.memo(function Child({ count, onClick }) {
console.log("child component is rendering");
return (
<div>
{/* ... */}
<button onClick={onClick}>Increment</button>
{/* ... */}
</div>
);
});
This code focuses on the incrementCount
function passing to the ChildComponent
. Currently, whenever the App
component re-renders, even if the count button is not clicked, the function gets redefined, resulting in the re-rendering of the Child
as well.
In order to avoid redefinition of the function, we will use a useCallback
Hook that returns a memoized version of the callback between renders.
Using the useCallback()
Hook
Utilizing useCallback
Hook, the incrementCount
function will only be redefined when the count dependency array changes:
const incrementCount = useCallback(() => setCount(count + 1), [count]);
Using the useMemo()
Hook
As we learnt earlier, non-primitive values like array and object point to different spaces in memory. We can use the useMemo
Hook to avoid re-computing the same expensive value in a component. It allows us to memoize
these values and only re-compute them if the dependencies change.
Just like the useCallback
Hook, the useMemo
Hook also requires a function and an array of dependencies as its arguments:
const memoizedValue = useMemo(() => {
// expensive computation
}, []);
Here is a sample of how to use useMemo
to improve React app’s performance. In the code below, we intentionally designed it to have a significant delay and execute slowly.
import React, { useState } from "react";
const delayedFunction = (count) => {
// expensive computation
for (let i = 0; i < 1000000000; i++) {}
return count * 3;
};
export default function App() {
// ...
const myCount = delayedFunction(count);
return (
<div>
{/* ... */}
<h3>Count x 3: {myCount}</h3>
<hr />
<Child count={count} onClick={incrementCount} />
</div>
);
}
const Child = React.memo(function ChildComponent({ count, onClick }) {
// ...
});
On every render, the delayedFunction
is invoked and this slows down the app.
The delayedFunction
should only be invoked when the count button is clicked, not when we type in the input field. We can memoize
the returned value by delayedFunction
using the useMemo
Hook so that it only re-computes the function only when needed(when the count button is clicked).
To accomplish that, we will have something like this:
const myCount = useMemo(() => {
return delayedFunction(count);
}, [count]);
These techniques come with their tradeoffs, and we should only use them when needed(e.g. when we have an expensive function). Memoizing all pages and components in your application will mostly create more performance issues that didn't exist before. You can learn more about when and when not to apply useCallback
and useMemo
in this article.
List Virtualization or Windowing
When dealing with the rendering of an extensive table or data list, the performance of our application can be noticeably affected.
Employing the virtualization concept, we can selectively render to the DOM only the portion of the content that is currently visible to the user. As the user scrolls, the remaining list items are dynamically rendered, replacing the items that have exited the viewport. This technique can greatly improve the rendering performance of a large data list. Virtualization can easily be implemented using libraries like react-window and react-virtualized.
Of course, this technique should only be used when we have a large data list.
Lazy Loading
To enhance the performance of an application that includes numerous images, we can avoid rendering all of the images at once to improve the page load time. With lazy loading, the images are rendered in the DOM only when they are about to become visible within the viewport
Lazy loading is similar to virtualization and can also be implemented using libraries like react-lazy-load and react-lazy-load-component.
UseTransition() Hook
useTransition()
is a new Hook added at React 18, and lets you update the state without blocking the UI.
Some UI updates necessitate immediate execution for a seamless user experience(typing into an input field, selecting a value from a dropdown), while some can be assigned lower priority(filtering a list). useTransition()
allows us to mark a state change as a lower priority so it doesn't block high-priority state change.
Take a look at the example below:
import { useMemo, useState } from 'react';
import Tasks from 'sample-component-directory';
import { filterTasks } from 'sample-function-directory';
const Application = () => {
const [filter, setFilter] = useState([]);
const visibleTasks = useMemo(
() => filterTasks(filter),// a sample fuction to filter the tasks
[filter],
);
const handleChange = (event) => {
setFilter(event.target.value);
};
return (
<main>
<input onChange={handleChange} value={filter} type="text" />
<Tasks tasks={visibleTasks} />
</main>
);
};
export default Application;
In the code above, we have Tasks
component that accepts a large list of tasks and an input that filters through the tasks. Every time the user types in the input, it updates the filter
state and filters the tasks to display the visibleTasks
. With this implementation, the typing will lag and the UI will feel unresponsive for a noticeable period. Why does this happen?
Updating the input field value when the user types is an urgent task that must perform fast. However, filtering the list by the filter is a heavy but non-urgent task. The heavy non-urgent task slows down the light urgent task.
useTransition()
hook can help us separate high-priority from low-priority UI updates.
The code becomes something like this:
import { useMemo, useState, useTransition } from "react";
import Tasks from "sample-component-directory";
import { filterTasks } from "sample-function-directory";
const Application = () => {
const [input, setInput] = useState("");
const [filter, setFilter] = useState([]);
const [isPending, startTransition] = useTransition();
const visibleTasks = useMemo(
() => filterTasks(filter), // a sample fuction to filter the tasks
[filter]
);
const handleChange = (event) => {
setInput(event.target.value);
startTransition(() => setFilter(event.target.value));
};
return (
<main>
<input onChange={handleChange} value={input} type="text" />
{isPending ? <p>Loading...</p> : <Tasks tasks={visibleTasks} />}
</main>
);
};
export default Application;
We created a separate state for updating field input, now when the user types, the input field is updated immediately and setFilter
is marked non-urgent with startTransition
from useTransition
. Also, the isPending
is used to show the user a loading state while the filtering is being done.
Now, the user gets immediate feedback from the input field, while the filtering is done in the background without affecting the user. Thanks to useTranstion()
🥳
Conclusion
To maximize our React Performance, we must first find a performance problem in our application to rectify. In this article, we have learned about the optimisation mindset we should hold, coding patterns to apply during development and optimisation techniques to employ after finding a performance issue.
Thank you for reading
I appreciate the time we spent together. I hope this content will be more than just text. Follow me on Linkedin and subscribe to my Youtube Channel where I plan to share more valuable content. Also, Let me know your thoughts in the comment section.