Introduction
It’s a long read, so grab a comfy seat and enjoy 🍻.
React.js is a popular JavaScript library for building user interfaces, known for its flexibility and performance. With React.js, you can create reusable components that make complex applications easy to build and maintain.
Hi there! I recommend trying out the live code examples as you read this post. Go ahead and read on! Cheers ✌️.
Facebook created React.js, and it has since become one of the most popular choices for front-end development, thanks to how easily it handles complex, dynamic applications.
In a React.js app, components are the building blocks that make up the user interface. They can be composed together to create a hierarchical tree of components, with the root component representing the entire app.
When a component’s state or props (short for properties) change, React.js efficiently updates the DOM to reflect these changes. This keeps React.js apps fast and responsive, even when they deal with large amounts of data.
However, as a React.js app grows in complexity and scale, developers need to take steps to optimize its performance. That can include techniques like code-splitting, which reduces the size of the initial bundle the browser loads, and memoization, which makes function calls more efficient by caching their results.
In this post, we’ll cover code-splitting and memoization with React.memo, React.useMemo, and React.useCallback. Whether you’re new to React.js or an experienced developer looking to take your app’s performance to the next level, this post is for you.
Optimizing Performance
When it comes to building web applications, performance is critical. A slow or unresponsive app can frustrate users and lead to a poor user experience. In today’s competitive market, it’s more important than ever to make sure your app delivers the best possible experience to your users.
But performance isn’t just about user satisfaction. It can also have a direct impact on your business. For example, if your app takes too long to load, users may abandon it before it even has a chance to show what it can do. That means lost customers and revenue. On the other hand, a fast and responsive app keeps users engaged and makes them more likely to return.
Performance matters for any web application. Here are just a few reasons to care about the performance of your React.js app:
-
User Experience: A slow or unresponsive app frustrates users, and they may abandon it altogether. On the other hand, a fast and smooth app can improve the user’s overall experience and keep them coming back for more.
-
SEO: In today’s digital landscape, your website or app needs to rank well in search engine results. One factor that can affect your ranking is the loading speed of your site. If your React.js app is slow to load, it could hurt your search engine ranking and make it harder for users to find your app.
-
Conversion Rates: A slow app can also impact your business’s bottom line. Studies have shown that faster loading times can lead to higher conversion rates, meaning more users taking the desired action (such as making a purchase).
-
Scalability: As your React.js app grows in complexity and scale, it needs to handle the increased load. Optimizing its performance helps it stay stable and scalable as it grows.
In short, optimizing the performance of your React.js app matters for both user satisfaction and the success of your business. In the following sections, we’ll explore advanced techniques for doing just that.
Code-Splitting
Code-splitting is a technique that lets developers split their code into smaller, more manageable chunks, or bundles. The app can then load these bundles on demand, rather than all at once on first load.
This improves the performance of a React.js app by shrinking the initial bundle the browser has to download, so the app loads faster.
Code-splitting is especially useful for larger, more complex apps with a lot of code, since it breaks the code into smaller pieces that load as needed. This reduces the overall size of the app, which can improve its performance and make it easier to maintain.
There are several ways to implement code-splitting in a React.js app, such as using the React.lazy and React.Suspense components, or using a tool like Vite.js, opens in a new tab.
Suspending Components
To implement code-splitting in your React application, you can use React.lazy and React.Suspense.
React.lazy lets you dynamically import a component, so it only loads when it’s needed. It does this with the import() function, which returns a promise that resolves to the module you’re importing.
React.Suspense is a component you wrap around a lazily loaded component. It displays a fallback while that component’s code loads.
Here’s an example of how you can use React.lazy and React.Suspense in your application:
App.tsx
import { Suspense, lazy } from "react";
const MyComponent = lazy(() => import("./MyComponent"));
export default function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
);
}In this example, MyComponent won’t load until it’s needed, and the fallback component displays while its code loads. This improves the performance of your application because it only loads the code it needs.
Bundling with Vite.js
To read more about Vite.js, check out the Vite.js guide, opens in a new tab.
Besides React.lazy and React.Suspense, another way to implement code-splitting in your React application is to use Vite.js as your frontend tooling.
Vite.js is a lightweight, simple, and fast build tool designed specifically for modern JavaScript applications. It uses Rollup under the hood, so you can use all the same code-splitting techniques in your Vite.js projects that you’d use with Rollup.
One benefit of using Vite.js for code-splitting is that it lets you easily split your code into different chunks, including splitting vendor code from your own code. This improves the performance of your application by loading only the code needed at a given moment, rather than loading everything upfront.
Let’s split vendor code from our own code. Take a look at this config:
vite.config.ts
import { dependencies } from "./package.json";
const exclVendors = ["react", "react-router-dom", "react-dom"];
function renderChunks(deps: Record<string, string>) {
let chunks = {};
Object.keys(deps).forEach((key) => {
if (exclVendors.includes(key)) return;
chunks[key] = [key];
});
return chunks;
}
// https://vitejs.dev/config/
export default defineConfig({
build: {
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
...renderChunks(dependencies),
},
},
},
},
});In this code, the renderChunks function creates an object with a key for each dependency and a value that holds the dependency name. This gives you one chunk per dependency.
You may have noticed the array of excluded vendors in the code. It specifies which dependencies to leave out of the code-splitting process.
Here, the array includes react, react-router-dom, and react-dom, the core React dependencies, so they stay out of the code-splitting process. That’s because your whole application likely uses them, so splitting them into separate chunks may not help.
The rest of the dependencies go into the rollupOptions object, which configures the code-splitting process. They get split into separate chunks and load on demand, rather than all at once. Again, this improves performance by loading only the code needed at a given moment.
Overall, by excluding certain dependencies from code-splitting and adding the rest to the rollupOptions object, you can set up code-splitting in your Vite.js project and improve the performance of your React.js application.
So what types of components should you use React.lazy with, and which ones shouldn’t you lazy load?
A few types of components are good candidates for code-splitting with React.lazy. For example, if I have a feature that’s only used on one page of my application, I’d consider splitting it into a separate chunk that only loads when the user navigates to that page.
Here are some types of components that are good candidates for code-splitting with React.lazy:
- Components that are only needed in specific routes or areas of the application. For example, if a feature is only used on one page, you can split it into a separate chunk that only loads when the user navigates to that page.
- Components that are large or complex and may take a long time to load. Splitting them into smaller chunks helps reduce the initial load time of your application.
- Components that are used infrequently or by a small number of users. Here, code-splitting helps reduce the overall size of your application and improves its performance for most users.
On the other hand, some types of components may not be good candidates for code-splitting with React.lazy:
- Components that are needed immediately on initial load. If a component is critical to the initial render, splitting it means the user has to wait for it to load before they can use the app.
- Components that are small and simple, where code-splitting may not bring significant performance benefits. It’s often more efficient to leave them in the main bundle and avoid the overhead of a dynamic import.
In general, weigh the trade-offs carefully when deciding which components to split. Code-splitting is a powerful tool for improving the performance of a React application, but consider the specific needs of your app before you implement it.
Memoization
Memoization is a technique that speeds up your app by storing the results of expensive function calls and returning them when the same inputs come up again. It’s especially useful for functions that get called a lot with the same arguments, since it saves you from recalculating the result every time.
In React, you can use memoization to make functional components run more efficiently. By default, React re-renders a functional component whenever its props or state change. That becomes a problem if the component gets called a lot with the same props, since it causes unnecessary re-renders. Memoization solves this by “memoizing” the component and only re-rendering it when the props change.
There are a few ways to use memoization in a React app. One option is the React.useMemo hook, a built-in React feature that lets you memoize a value. Then there’s the React.useCallback hook, also built in, which memoizes a function instead of a value. Last but not least, there’s React.memo, another built-in feature. The difference is that it isn’t a hook but a Higher-Order Component that lets you memoize a component.
Memoization is like caching for your React.js app. Here are a few reasons why it matters:
-
Improved performance: As mentioned earlier, memoization improves the performance of your app by avoiding unnecessary re-renders of functional components. This is especially useful for components that are called frequently with the same props, since it saves a lot of time and resources by skipping the recalculation of the component’s result.
-
Improved user experience: By optimizing the performance of your app, you improve the overall user experience. Faster loading times and smoother transitions between screens can make your app feel more responsive and enjoyable to use.
-
Reduced complexity: Memoization can also reduce the complexity of your code by letting you write simpler, more declarative components. Without memoization, you might have to write more complex logic to avoid unnecessary re-renders, which can make your code harder to understand and maintain.
Memoizing Values
The useMemo hook is a built-in React hook that lets you memoize a value. It takes a function as an argument and returns a memoized version of the value that function returns.
The memoized value is only recomputed when one of the dependencies listed in the hook’s second argument changes. This helps optimize functional components by avoiding unnecessary recalculations of expensive values.
Take a look at the basic syntax of the React.useMemo hook:
TSX
const value = useMemo(() => computedExpensiveValue(buzz, fizz), [buzz, fizz]);In this example, the computedExpensiveValue function is called with arguments buzz and fizz, and the result is memoized. The memoized value will only be recomputed if the values of buzz or fizz change.
Another use case for React.useMemo is filtering and sorting large data sets. Let’s say you want to display a list of products in a component and let the user filter and sort it by various criteria. You can use the useMemo hook to memoize the filtered and sorted list, like so:
@/components/product-list.tsx
import { useMemo } from "react";
export const ProductList = ({ products, filter, sortBy }) => {
// Memoize the filtered and sorted version of the product list
const filteredAndSortedProducts = useMemo(() => {
// Filter the products based on the filter criteria
let filteredProducts = products;
if (filter) {
filteredProducts = filteredProducts.filter((product) =>
product.name.includes(filter)
);
}
// Sort the products based on the sortBy criteria
return filteredProducts.toSorted((a, b) => {
if (a[sortBy] < b[sortBy]) return -1;
if (a[sortBy] > b[sortBy]) return 1;
return 0;
});
}, [products, filter, sortBy]);
return (
<ul>
{filteredAndSortedProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
};In this example, the ProductList component filters and sorts the product list based on the filter and sortBy props, and stores the result in a memoized value. This improves the component’s performance by avoiding unnecessary recalculations of the filtered and sorted list on every re-render.
Don’t Memoize Everything
React.useMemo lets you optimize your functional components by memoizing values that are expensive to compute. But there are a few situations where it might not be the best choice:
-
If the value you’re computing is super simple or cheap to compute, it might not be worth the extra overhead of
React.useMemo. The performance boost might not be worth the extra code. -
If the value is only used once or a few times in the component, the overhead might not pay off either. The performance boost might not be significant enough to justify the extra code.
-
If the value depends on props that change very frequently,
React.useMemomight not keep up with the changes, and you could end up with stale data. Here, consider a different optimization technique, likeReact.useEffectorReact.useReducer, to avoid unnecessary re-computations.
React.useMemo is a good way to improve your React app’s performance, but skip it if the value is super simple or cheap to compute, only used a few times in the component, or depends on frequently changing props. In most other cases, React.useMemo is a really useful way to optimize your functional components.
Memoizing Callbacks
The useCallback hook is a built-in React hook that lets you create a memoized version of a callback function. It takes a function and an array of dependencies, and returns a memoized version of the function that only changes when one of the dependencies changes.
This helps optimize functional components by avoiding unnecessary re-creations of callback functions.
Take a look at the basic syntax of the useCallback hook:
TSX
const callback = useCallback(() => {
doSomething(longitude, latitude);
}, [longitude, latitude]);In this example, the callback function is created using the useCallback hook, and it will only be re-created if the values of longitude or latitude change.
Another use case for React.useCallback is passing callbacks to deeply nested components. If you have a complex component hierarchy and want to pass a callback down to a child component, useCallback avoids re-creating that callback on every render. This improves your app’s performance by avoiding unnecessary re-renders of the child component.
Let’s say you have a parent component that renders a list of items, and each item has a delete button that calls a delete callback when clicked. You can use the useCallback hook to create a memoized version of the delete callback and pass it down to the child component like this:
@/components/parent-component.tsx
import { useCallback } from "react";
const ParentComponent = ({ items, onDelete }) => {
// Create a memoized version of the onDelete callback
const handleDelete = useCallback(
(itemId) => {
onDelete(itemId);
},
[onDelete]
);
return (
<ul>
{items.map((item) => (
<ChildComponent key={item.id} item={item} onDelete={handleDelete} />
))}
</ul>
);
};In this example, the ParentComponent function component renders a list of ChildComponent components and passes each one a callback for deleting items. The useCallback hook creates a memoized version of that callback, which improves the performance of ParentComponent by avoiding unnecessary re-creations of the function. Each ChildComponent can then use the callback to delete its item when the delete button is clicked.
That said, there are a few situations where this hook might not be the best choice:
-
If the callback function is super simple or cheap to create, it probably doesn’t make sense to use
React.useCallback. The extra overhead might not be worth it. -
If the callback function is only used a few times in the component,
React.useCallbackmight not pay off. The performance boost might not be significant enough. -
If the callback function depends on props that change very frequently,
React.useCallbackmight not keep up with the changes, and you could end up with a stale function. Here, consider a different optimization technique.
Memoizing Components
React.memo optimizes functional components in React by preventing them from re-rendering unnecessarily. It does this by “memoizing” the component, which means it only re-renders when the props actually change.
Here’s an example of how you can use React.memo:
@/components/my-component.tsx
import { memo } from "react";
const MyComponent = ({ name }) => {
console.log("Render MyComponent");
return <div>Hello, {name}!</div>;
};
export default memo(MyComponent);In this example, MyComponent renders a greeting with the name prop. Wrapping it with React.memo tells React to only re-render the component when the name prop changes. This improves your app’s performance by avoiding unnecessary re-renders of the component.
You can use React.memo to optimize any functional component that doesn’t need to re-render every time its props change. It’s a simple and effective way to speed up your React app.
Basically, React.memo optimizes functional components by avoiding unnecessary re-renders. But there are a few situations where it might not be the best choice:
-
If the component doesn’t have any expensive calculations or props (e.g. large
objectsorarrays),React.memomay not bring much of a performance benefit. The overhead ofReact.memomight even cost more than just re-rendering the component. -
If the component has a lot of state or props,
React.memomight actually make it slower, since the HOC has to compare all of that state and those props on every update. Here, theReact.useMemoorReact.useReducerhook might be a more efficient way to optimize the component. -
If the component updates very frequently (e.g. on every tick of a timer),
React.memomight not be efficient, since the HOC has to compare the previous and current props on every update. In that case, a class component or theReact.useReducerhook might work better.
I hope this gives you a sense of when to reach for React.memo and when to skip it.
Closing
We’ve looked at several techniques for optimizing the performance of a React.js app, including code-splitting and memoization. With these techniques, you can improve your app’s performance and give your users a better experience.
Here are a few additional tips for optimizing the performance of your React.js app:
-
Use the React Developer Tools, opens in a new tab browser extension to profile your app and identify performance bottlenecks.
-
Consider using a performance monitoring tool like LogRocket, opens in a new tab to track the performance of your app over time.
-
Consider using a state management library like Jotai, opens in a new tab or Zustand, opens in a new tab to easily manage your app’s state.
Thank you for reading this blog post! I hope you found it helpful. See you in my next post!