As software developers, we always want to create high-performing applications and write more efficient code as we ascend the ladder of expertise. React, a popular library known for its efficient rendering, optimization techniques play a critical role in ensuring a positive user experience.
In this article, we will be exploring some strategies and techniques in React that can improve your application’s performance.
The Foundation: Understanding React Rerendering
React components automatically rerenders when there is a change in their state or props. While this behaviour is beneficial, it can lead to performance issues if not managed carefully.
Consider a scenario where a parent component rerenders. In React’s default behaviour, all of its child components, even those with unchanged states or props, will also rerender. This behaviour, though intuitive, can result in unnecessary performance overhead, particularly for components with expensive computations during each render.
Code Illustration: Unveiling the Rerendering Challenge
The code sample below shows that whenever a parent component rerenders, all of its child components rerender regardless of whether a prop passes to them or not.
import { useState } from "react";
export default function App() {
const [input, setInput] = useState("");
return (
<div>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<h3>Input: {input}</h3>
<ChildComponent />
</div>
);
}
export function ChildComponent() {
console.log("child component is rendering");
return <div>This is child component.</div>;
}
In this example, as we type into the input field, the ChildComponent rerenders with each keystroke, this can cause a huge performance issue especially when the child component performs an expensive computation each time it renders.
Optimization Strategies
1. Keep Component State Local Where Necessary
Since we know that an update in the parent component causes both parent and child components to rerender, we can ensure that a component renders only when necessary, by extracting the state and making it local to that component.
Here we refactor the code:
import { useState } from "react";
export default function App() {
return (
<div>
<FormInput />
<ChildComponent />
</div>
);
}
function FormInput() {
const [input, setInput] = useState("");
return (
<div>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<h3>Input text: {input}</h3>
</div>
);
}
function ChildComponent() {
console.log("child component is rendering");
return <div>This is child component.</div>;
}
In this refactored code, only the component using the state (FormInput) rerenders when the state changes, preventing unnecessary rerenders in other components.
This is great, however sometimes we cannot avoid having a state in a global component and pass it down to child components as a prop, for such cases, we can employ other techniques.
2. Memoizing React Components
Memoization is a powerful optimization technique that caches the result of a rendered component and returns the cached result if the input remains the same. React provides the React.memo() function to memoize functional components.
const ChildComponent = React.memo(function ChildComponent({ count }) {
console.log(“child component is rendering”);
return (
<div>
<h2>This is a child component.</h2>
<h4>Count: {count}</h4>
</div>
);
});
In the above example, using React.memo() ensures that the child component only rerenders when its props (primitive data) changes. However, if we were passing down an object, array or a function as props, React.memo will not work and the child component will rerender.
This happens because the object (or array or function) is redefined on each new render, making the memo function to see it as a change(or updated object) causing it to rerender the component.
To prevent the function from always redefining, we can wrap it in a useCallback Hook and pass that as the prop instead of the raw function itself.
const incrementCount = React.useCallback(() => setCount(count + 1), [count]);
It is important to note that memoization comes with a memory cost, so we are trading memory space for time, hence we should use this technique only when necessary.
3. Code-Splitting with Dynamic Import
As your React application grows, loading the entire codebase to users at once can result in increased load times. Code-splitting allows us to split a large bundle file into multiple chunks using dynamic import() followed by lazily loading of these chunks on demand with React.lazy(). Hence we can refactor to:
Hence we can refactor from this:
import Home from "./components/Home";
import About from "./components/About";
to this:
const Home = React.lazy(() => import("./components/Home"));
const About = React.lazy(() => import("./components/About"));
This syntax tells React to load each component dynamically. So when a user follows a link to the home page, for instance, React only downloads the file for the requested page instead of loading a large bundle file for the entire application.
After the import, we must render the lazy components inside a Suspense component like so:
<React.Suspense fallback={<p>Loading page...</p>}>
<Route path="/" exact>
<Home />
</Route>
<Route path="/about">
<About />
</Route>
</React.Suspense>
The Suspense component allows us to display a loading text or indicator as a fallback while React waits to render the lazy component in the UI.
4. Windowing or List Virtualization in React
Rendering a large list in its entirety, whether or not items are visible, can lead to performance issues. Windowing, or list virtualization, involves rendering only the visible portion of the list and dynamically rendering the remaining items as the user scrolls.
Libraries like react-window and react-virtualized provide efficient windowing implementations for React.
5. Lazy Loading Images in React
Similar to windowing, lazy loading images involves rendering images only when they are about to become visible in the viewport. Libraries like react-lazyload and react-lazy-load-image-component offer solutions for lazy loading images in React.
Additional Tips and Considerations
- Images and Media Files Optimization:
- Resize images and limit upload sizes.
- Use Content Delivery Networks (CDNs) like Cloudinary.
- Apply compression and optimization techniques for media files.
- State Management:
- Favour the Context API mixed with useReducer over Redux because with context api you use less javascript, its part of react js library, but with redux, you import more javascript libraries.
- UI Libraries Usage:
- Avoid importing entire UI libraries when only a few components are needed.
- Consider lightweight options like Tailwind CSS for optimised CSS.
- Lazy Loading for Most Things:
- Load data and content on demand based on routes.
- Optimise resource usage by loading only what is necessary for the current view.
- Progressive Web Apps (PWAs):
- While PWAs offer benefits, be mindful of the performance cost due to increased code shipping.
- Use Unique Keys in Components:
- When mapping through components, ensure each has a unique key to help React identify components efficiently.
- Memoization and Callbacks:
- Utilise memoization and callbacks to speed up function calls where applicable.
In conclusion, optimising React applications requires careful consideration of component states, memoization, code-splitting, windowing, and lazy loading. By incorporating these strategies and tips into your development workflow, you can ensure that your React applications deliver a responsive user experience. Happy optimising!