Optimizing React Performance: Strategies for Faster, Smoother Apps
If we were to develop a brand new proof of concept or even a minimal viable product today, its complexity would increase immeasurably as time goes on. Because we would need to prototype quickly to achieve fast results, we would have to rely on community-built solutions that would address our issues and we would simply have a composition of components and modularization that was not properly planned.
I know that the previous paragraph was generic, but if it is not resolved quickly, the technical debt will accumulate over time until it becomes very difficult to carry out any maintenance on the application.
I touched on several subjects, but some of the issues that the user may encounter include a slower initial loading, lengthy page transitions and the occasional page freezes whenever he/she interacts with an element that will trigger some expensive task. For that same reason, I'm going to share some tips, taking into account my personal experience. And without further ado, let's tackle the next section.
Prerequisites
Before going any further, it is expected that you have a brief idea of how React and React Router work.
Code Splitting
This technique involves dividing the code into small pieces that can be loaded as needed into modules, hooks, and React components. In my opinion, this has two distinct levels, the route and the component level. At the route level, we load all the modules that are needed for a specific page. While at the component level, ideally, it has a smaller scope and we would only have to import the code that would be necessary for it.
It is interesting to have this perspective because we end up planning much more before implementing it. To make it easier, let's tackle the component level first.
Component Level
The most popular way to dynamically load modules is to import them when visible or interacted with. However, this should be addressed in specific scenarios and should not be a priority, because sometimes it ends up being pure overhead.
In my opinion, the best way to approach the level component is granular, that is, by taking advantage of tree shaking. The reason is simple, the production environment does not need to have unused code in the bundle(s). Let's consider the following module:
// utils.js
export function sum(x, y) {
return x + y;
};
export function double(x) {
return x * 2;
};
In the example above, we have defined two functions, sum
and double
. Now in the next example, we will import only the double
inside the component:
import { double } from "./utils";
export default function MathCell() {
return (
<span>Value: {double(4)}</span>
);
};
In theory, in the production environment the sum
function would be excluded from the bundle because despite being defined in the utils
module, it is not being used at any time in the above code block.
We can have this approach when we create utility functions, domain functions, and hooks, among others. But for React components, to take advantage of code splitting we need to do it this way:
import { lazy } from "react";
// If exported with named exports
export const LazyMathCell = lazy(
() => import("./MathCell")
.then((module) => ({
default: module.MathCell
})),
);
// If exported with export default
export const LazyMathCell = lazy(() => import("./MathCell"));
In the code above, we used React lazy
primitive to dynamically import the functional component, as is noticeable in the named exports export, this primitive returns a promise that resolves to a module.
When we import statically, we can import the component and then add it to JSX without any problem, however with a dynamic import the ideal would be to take advantage of a Suspense
boundary, allows a fallback to be shown while the dynamic component is downloaded and rendered.
import { Suspense } from "react";
import { LazyMathCell } from "../components/MathCell";
export default function Page() {
return (
<div>
<h3>Good Looking Page</h3>
<Suspense fallback={<small>Loading...</small>}>
<LazyMathCell />
</Suspense>
</div>
);
};
In the example above, we import the Suspense
primitive from React, then we import the LazyMathCell
component is lazy
loaded and in JSX we wrap the functional component with the boundary. It is expected that the first load does not have the component included in the Page
bundle and meanwhile it is expected that the fallback will be shown until it is fully loaded. With this in mind, we can now move on to the next section.
Route Level
If the application is large enough, most likely the user will not visit all routes that are exposed, especially if we have several nested routes. Bearing this in mind, I believe that it makes more sense to invest the first efforts in splitting the routes than the components themselves.
This is because if we were to break it down into smaller parts, the scope of the route would be larger when compared to the component. This often leads to the usage of certain modules, components, and dependencies that are unique to a particular page, which may not be appropriate if that page is rarely visited.
Therefore, when splitting at the route level, we can fetch only the resources that are needed for the current route, instead of loading the entire website. If we take into account the react-router-dom
dependency, let's first look at an example without the data API:
import { Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
const LazyHome = lazy(() => import("./Home"));
const LazyLayout = lazy(() => import("../layouts/Main"));
const LazyAccount = lazy(() => import("./Account"));
const Loading = () => <h3>Loading...</h3>;
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/">
<Route
index
element={
<Suspense fallback={<Loading />}>
<LazyHome />
</Suspense>
}
/>
<Route
path="account"
element={
<Suspense fallback={<Loading />}>
<LazyLayout />
</Suspense>
}
>
<Route
path="main"
element={
<Suspense fallback={<Loading />}>
<LazyAccount />
</Suspense>
}
/>
</Route>
</Route>
</Routes>
</BrowserRouter>
);
}
In the code snippet above we start by importing the Suspense
primitive, as well as the react-router-dom
primitives. Then we load the components of the pages that we want to use to assign the routes of the application.
The component that was defined next was Loading
, which will be used as a fallback while the page components are being downloaded and rendered.
Then on each route, we wrap the lazy-loaded component with the Suspense
boundary, even the Layout
component. This is to ensure that we don't send more resources to the client than are needed. To simplify this boilerplate, I recommend creating a High Order Component. To simplify the boilerplate done on each route, I recommend creating a High Order Component.
Now if we take into account the latest react-router-dom
API, we can take advantage of the new property called lazy
which will abstract everything we did in the previous example. But it's worth remembering that it only works with the data API, considering the following example:
import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom";
const routes = createRoutesFromElements(
<Route path="/">
<Route index lazy={() => import("./Home")} />
<Route path="account" lazy={() => import("../layouts/Main")}>
<Route path="main" lazy={() => import("./Account")} />
</Route>
</Route>
);
const router = createBrowserRouter(routes);
export default App(){
return (
<RouterProvider router={router} />
);
};
In the example above, we start by importing the react-router-dom
primitives and assigning the page components to the respective routes. And as you may have noticed, the code that was written was much smaller and does the same as the example before this one.
Apart from that, one of the things I recommend doing is adding an error boundary, ideally, it would be to each of the routes, but if that's not possible add it at least to the root of the App
. This is because an error may occur in component rendering and it may bubble up until it is caught in an error boundary.
Once the code splitting is complete, we can move on to the next section.
Concurrent Mode
Applications that are CSR (client-side rendered) end up storing a lot of data in memory and from that same data, we render countless components on the screen. Sometimes we have several data structures that differ from the originals and as soon as we make a change it triggers a side-effect, if we are talking about thousands of components, some components may not be so snappy for a few milliseconds as soon as we interact with an element.
The easiest way is to use the startTransition
primitive, as follows:
import { startTransition, useCallback } from "react";
function Tabs() {
const [selectedTab, setSelectedTab] = useState(0);
const selectTabHandler = useCallback((nextTab) => {
startTransition(() => {
setSelectedTab(nextTab);
});
}, []);
// ...
};
In the example above, what happens is that through the index
of the selected tab, we will render the corresponding content, ideally, we would wrap the content of each tab with a Suspense
, so that the necessary resources for each tab are consumed, be they components, dependencies or even HTTP requests that are made to an API.
When using the startTransition
, we update the state of the selectedTab
without blocking the main thread and we only show the tab's content after all the necessary procedures have been successfully performed.
While startTransition
is ideal when triggering a side-effect, we can also take advantage of other primitives like useDeferredValue
. Through this, we can defer the update of the state of a portion of the screen when, for example, there is a change in the prop of a component or a variable defined in the component.
import { useDeferredValue } from "react";
import { useTable } from "../../components/Table/hooks";
function TableFilters() {
const { filtering } = useTable();
const deferedFiltering = useDeferredValue(filtering);
return <DropdownMenu options={deferedFiltering} />;
};
Taking into account the previous example, imagining that all the logic of the table is agnostic and the data that is passed in the props is systemic, as they change they cause other updates, we can take advantage of useDeferredValue
to wait for everything to be updated/ready before reflecting these changes on the screen, without blocking the screen (if it is a bit expensive operation).
With this in mind, we can move on to the next section.
Web Workers
All JavaScript code runs on the main thread, sometimes we have so much going on at the same time that the main thread hangs briefly, or when we are doing more expensive processing.
One of the great advantages of using Web Workers
is that we free up the resources of the main thread, and we can isolate long-running tasks to a Web Worker
thread and thus reduce problems where the application simply slows down.
Although React has Concurrent Mode
, I think it's ideal to use it at the UI level, to bring more benefits to the user experience. Whereas Web Workers
should be used to perform operations that are expensive for the UI, such as executing very expensive business logic.
Using Web Workers
is more complicated compared to using React primitives that can be simply imported and used. However, to simplify the entire configuration process and avoid the need for postMessage
, a library called Comlink
can be utilized.
I could give an example, but it deserves a whole future article on Workers
. In this article, I hope you learned the following:
A bit about code splitting and tree shaking
Route based splitting
Some primitives of React Concurrent Mode
A short introduction to Web Workers
I hope you found this article helpful. Please let me know if you notice any mistakes in the article by leaving a comment.