Understanding React Performance Optimizations: Techniques

In smaller React apps, performance issues may not be noticeable. But in larger projects, especially with heavy workloads, performance problems can become more obvious.
Improving performance is important to ensure your React apps are fast and scalable. Poor performance can lead to higher costs and a bad user experience, which can hurt customer satisfaction.
In this article, I’ll share some useful techniques and examples to help you improve your React app’s performance. At the end, I’ll also include a link to the “React Performance Optimizations” GitHub project, where you can explore each technique in detail as separate components.
💡 Topics Covered in This Article
- Understanding Virtual DOM (VDOM)
- Implementing React.memo for Components
- Using useMemo and useCallback Hooks for Expensive Calculations
- Lazy Loading with React.lazy and Suspense
- Avoiding Inline Function Definitions
- Using Immutable Data Structures
- Use a Function in setState
- Key Coordination for List Rendering
- Implementation of PureComponent
- Lazy Loading Images
Understanding Virtual DOM (VDOM)
Before exploring techniques, it's important to understand the Virtual DOM (VDOM), a key feature of React. The VDOM is an in-memory representation of the real DOM, designed to optimize updates.
Instead of making changes directly to the real DOM, React first updates the VDOM, then compares it with the previous version in a process called reconciliation. Only the differences are applied to the actual DOM, reducing unnecessary re-renders and improving performance.
While the VDOM helps make React more efficient, in larger applications, unnecessary component updates can still occur. This is where specific optimization techniques come into play, such as React.memo
.
Implementing React.memo for Components
One of the most effective ways to prevent unnecessary re-renders in React is by using React.memo
. This higher-order component (HOC) optimizes performance by "memorizing" the rendered output of a component. If the component's props don't change between renders, React will reuse the last rendered output instead of re-rendering the entire component.
How It Works
By default, React re-renders a component whenever its parent re-renders, even if the component’s props haven’t changed. React.memo
solves this problem by doing a shallow comparison of the component's props. If the props are the same as the previous render, it skips rendering the component again.
Code Example
// src/components/ReactMemoExample.tsx
import React, { useState } from "react";
// Define the type for the list item
interface Item {
id: number;
name: string;
}
// Child component that is memoized using React.memo
const ListItem: React.FC<{ item: Item }> = React.memo(({ item }) => {
console.log(`Rendering: ${item.name}`);
return <li>{item.name}</li>;
});
// Parent component rendering the list
const ReactMemoExample: React.FC = () => {
const [items, setItems] = useState<Item[]>([
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" },
]);
// Function to add a new item to the list
const addItem = () => {
setItems((prevItems) => [
...prevItems,
{ id: prevItems.length + 1, name: `Item ${prevItems.length + 1}` },
]);
};
return (
<div>
<h2>React.memo Example</h2>
<ul>
{items.map((item) => (
<ListItem key={item.id} item={item} />
))}
</ul>
<button onClick={addItem}>Add Item</button>
</div>
);
};
export default ReactMemoExample;
Code Output
// first render
Rendering: Item 1
Rendering: Item 2
Rendering: Item 3
// when we click 'Add Item' button (1 line appended).
Rendering: Item 1
Rendering: Item 2
Rendering: Item 3
Rendering: Item 4
What happens if we don’t wrap the component with React.memo?
// first render
Rendering: Item 1
Rendering: Item 2
Rendering: Item 3
// when we click the 'Add Item' button (4 line appended).
Rendering: Item 1
Rendering: Item 2
Rendering: Item 3
Rendering: Item 1
Rendering: Item 2
Rendering: Item 3
Rendering: Item 4
Using useMemo and useCallback Hooks for Expensive Calculations
While React.memo
helps prevent unnecessary re-renders, useMemo
and useCallback
are two hooks that can help you optimize the performance of specific functions or values within your components. Both hooks are designed to "memoize" values, ensuring that expensive calculations or function definitions are only re-executed when their dependencies change.
useMemo for Memoizing Expensive Values
useMemo
is ideal for memoizing expensive computations that occur within a component. It ensures that a computation is only recalculated if the dependencies change. This can be especially useful when dealing with data-heavy applications or calculations that may take significant time or resources to complete.
Code Example
// src/components/UseMemoExample.tsx
import React, { useState, useMemo } from "react";
const UseMemoExample: React.FC = () => {
const [count, setCount] = useState(0);
const [items] = useState([1, 2, 3, 4, 5]);
// Memoized value only recalculated when 'count' changes
const expensiveCalculation = useMemo(() => {
console.log("Expensive calculation running...");
return items.reduce((total, item) => total + item * count, 0);
}, [count]);
return (
<div>
<h2>useMemo Example</h2>
<p>Expensive calculation result: {expensiveCalculation}</p>
<button onClick={() => setCount(count + 1)}>Increment Count</button>
</div>
);
};
export default UseMemoExample;
In this example, expensiveCalculation
will only be re-executed if count
changes, so saving computation time during re-renders.
Code Output
// first render
Expensive calculation running...
// when we click the 'Increment Count' button (1 line appended).
Expensive calculation running...
Expensive calculation running...
useCallback for Memoizing Functions
useCallback
works similarly to useMemo
, but instead of memoizing values, it memoizes functions. It's particularly useful when passing callback functions as props to child components that rely on reference equality to avoid re-renders.
Code Example
// src/components/UseCallbackExample.tsx
import React, { useState, useCallback } from "react";
const UseCallbackExample: React.FC = () => {
const [count, setCount] = useState(0);
// Memoized function only recalculates when 'count' changes
const handleClick = useCallback(() => {
console.log("Button clicked with count:", count);
}, [count]);
return (
<div>
<h2>useCallback Example</h2>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment Count</button>
<button onClick={handleClick}>Log Count</button>
</div>
);
};
export default UseCallbackExample;
Without useCallback
, the handleClick
function would be recreated on every render, potentially causing unnecessary re-renders in child components that receive handleClick
as a prop.
Lazy Loading with React.lazy and Suspense
Another effective optimization technique is lazy loading components. Instead of loading all components at once, you can load them only when needed. This can significantly reduce the initial load time of your application and improve perceived performance.
React provides built-in support for lazy loading with the React.lazy
function and Suspense
component. This allows you to defer loading components until they are actually needed.
// src/components/ReactLazyExample.tsx
import React, { Suspense } from "react";
// Lazy load the component
const LazyComponent = React.lazy(() => delayForDemo(import("./LazyLoadedComponent")));
const ReactLazyExample: React.FC = () => {
return (
<div>
<h2>React.lazy and Suspense Example</h2>
<Suspense fallback={<div>Loading Component...</div>}>
<LazyComponent />
</Suspense>
</div>
);
};
const delayForDemo = (promise: Promise<any>): Promise<any> => {
return new Promise(resolve => {
setTimeout(resolve, 2500);
}).then(() => promise);
}
export default ReactLazyExample;
In this example, LazyComponent
will only load when it has been rendered according to its delay, and the Suspense
component will display a fallback UI (for example, a loading spinner) while the component is loading.
// src/components/LazyLoadedComponent.tsx
import React from "react";
const LazyLoadedComponent: React.FC = () => {
return <div>I was loaded lazily!</div>;
};
export default LazyLoadedComponent;
Avoiding Inline Function Definitions
Inline function definitions in JSX can lead to unnecessary re-renders. Every time a component re-renders, a new instance of the inline function is created, which can trigger re-renders in child components. By moving these functions outside of the component or using useCallback
as mentioned earlier, you can prevent this issue.
Bad Practice:
<button onClick={() => handleClick(id)}>Click Me</button>
Improved Approach:
const handleClick = useCallback((id) => {
// Handle click
}, [id]);
<button onClick={() => handleClick(id)}>Click Me</button>
Another Full Code Example
// src/components/AvoidInlineFunctionExample.tsx
import React, { useState, useCallback } from "react";
const AvoidInlineFunctionExample: React.FC = () => {
const [count, setCount] = useState(0);
// Avoiding inline function by using useCallback
const increment = useCallback(() => {
setCount((prevCount) => prevCount + 1);
}, []);
return (
<div>
<h2>Avoiding Inline Functions Example</h2>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
};
export default AvoidInlineFunctionExample;
Using Immutable Data Structures
Immutable data structures are types of data that can’t be changed once created. Instead of directly changing the existing data, you make a copy and apply the changes to the new version. This way, the original data stays the same.
Why Is This Important in React?
In React, performance can improve if we use reference comparison. React checks if a component needs to re-render by comparing the old and new data. If the reference (or memory address) is different, React knows there’s been a change and re-renders the component. Using immutable data helps React easily spot these changes.
If you change data directly (mutate it), React might miss the change because the reference stays the same. But if you use immutable data, you always give React a new reference, making it easier to detect the changes and re-render the component if needed.
Example: Mutation vs. Immutable
Bad Example (Mutation):
const numbers = [1, 2, 3];
numbers.push(4); // The original array is changed
console.log(numbers); // [1, 2, 3, 4]
Here, we directly change the numbers
array. React might not notice this change because the reference is still the same.
Good Example (Immutable):
const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4]; // A new array is created
console.log(newNumbers); // [1, 2, 3, 4]
console.log(numbers); // [1, 2, 3] (the original array stays unchanged)
In this example, instead of changing the original array, we create a new array. This way, React can easily see the difference because we’re working with a new reference, which helps with performance.
Why Use Immutable Data?
- Performance: React compares references to find out if something has changed. With immutable data, the reference changes every time you update it, making React’s job easier.
- Undo/Redo: It’s easier to implement features like undo/redo because the previous data stays unchanged, and you always have a copy of the old state.
- Easier Debugging: Since data isn’t changed directly, you can easily track changes, making debugging easier.
Code Example
// src/components/ImmutableDataExample.tsx
import React, { useState } from "react";
const ImmutableDataExample: React.FC = () => {
const [items, setItems] = useState<number[]>([1, 2, 3]);
const addItem = () => {
// Using spread operator to keep immutability
setItems((prevItems) => [...prevItems, prevItems.length + 1]);
};
return (
<div>
<h2>Immutable Data Structures Example</h2>
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<button onClick={addItem}>Add Item</button>
</div>
);
};
export default ImmutableDataExample;
Immutable data structures help React work faster and make sure your data is handled safely. Every time you make a change, you create a new copy of the data, which keeps the original safe. This also makes it easier for React to detect changes, improving the performance of your app.
Use a Function in setState
When updating the state based on the previous state, it’s better to pass a function to setState
rather than a direct value. This ensures that the update is accurate, especially when there are multiple state updates happening asynchronously.
Performance Benefit: Using a function guarantees that React has the most up-to-date state value, which is crucial for preventing bugs and improving performance in complex components.
Code Example
// src/components/UseFunctionInSetStateExample.tsx
import React, { useState } from 'react';
const UseFunctionInSetStateExample: React.FC = () => {
const [count, setCount] = useState(0);
const increment = () => {
setCount(prevCount => prevCount + 1);
};
return (
<div>
<h2>Using Function in setState Example</h2>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
};
export default UseFunctionInSetStateExample;
Key Coordination for List Rendering
In React, using unique key
props when rendering lists helps React efficiently identify which items have changed, been added, or removed. This optimization allows React to minimize re-renders by reusing elements that haven’t changed
Performance Benefit: Properly using key
props allows React to optimize the reconciliation process, minimizing unnecessary re-renders and improving list rendering performance.
Code Example
// src/components/KeyCoordinationListRenderingExample.tsx
import React from 'react';
interface Item {
id: number;
name: string;
}
const items: Item[] = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Orange' },
];
const KeyCoordinationListRenderingExample: React.FC = () => {
return (
<div>
<h2>Key Coordination for List Rendering Example</h2>
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
};
export default KeyCoordinationListRenderingExample;
Implementation of PureComponent
If you are using class based component you can inheritance your component from PureComponent.
It is a more optimized version of React.Component
. It implements a shallow comparison of props and state to determine if a re-render is necessary. If neither the props nor the state has changed, React skips the render process.
Code Example
// src/components/PureComponentExample.tsx
import React, { PureComponent } from 'react';
interface Props {
message: string;
}
class PureComponentExample extends PureComponent<Props> {
render() {
console.log('Rendering: ', this.props.message);
return (
<div>
<h2>PureComponent Example</h2>
<p>{this.props.message}</p>
</div>
);
}
}
export default PureComponentExample;
Lazy Loading Images
Lazy loading images means that images are not loaded until they are about to be displayed in the viewport (the visible area of the screen). This technique improves performance, especially on pages with many images, by reducing the initial load time and bandwidth usage. Instead of loading all images immediately when the page loads, it only loads images when they’re needed.
Code Example
// src/components/LazyLoadingImagesExample.tsx
import React from 'react';
const LazyLoadingImagesExample: React.FC = () => {
return (
<div>
<h2>Lazy Loading Images Example</h2>
<img src="https://picsum.photos/id/1/2000/3000" height={250} width="auto" alt="First Image" loading="lazy" />
<img src="https://picsum.photos/id/2/2000/3000" height={250} width="auto" alt="Second Image" loading="lazy" />
<img src="https://picsum.photos/id/3/2000/3000" height={250} width="auto" alt="Third Image" loading="lazy" />
</div>
);
};
export default LazyLoadingImagesExample;
Conclusion
In this article, I talked about different ways to improve performance in React apps. Each technique has its own component, and I’ve put them all together in the App.tsx
file. Below, you can see how all the components are imported and used in the App
component:
// src/App.tsx
import React from "react";
import UseMemoExample from "./components/UseMemoExample";
import UseCallbackExample from "./components/UseCallbackExample";
import ReactLazyExample from "./components/ReactLazyExample";
import AvoidInlineFunctionExample from "./components/AvoidInlineFunctionExample";
import ImmutableDataExample from "./components/ImmutableDataExample";
import ReactMemoExample from "./components/ReactMemoExample";
import UseFunctionInSetStateExample from "./components/UseFunctionInSetStateExample";
import KeyCoordinationListRenderingExample from "./components/KeyCoordinationListRenderingExample";
import PureComponentExample from "./components/PureComponentExample";
import LazyLoadingImagesExample from "./components/LazyLoadingImagesExample";
const App: React.FC = () => {
return (
<>
<h1>React Performance Optimizations</h1>
<ReactMemoExample />
<UseMemoExample />
<UseCallbackExample />
<ReactLazyExample />
<AvoidInlineFunctionExample />
<ImmutableDataExample />
<UseFunctionInSetStateExample />
<KeyCoordinationListRenderingExample />
<PureComponentExample message="greetings from class component!" />
<LazyLoadingImagesExample />
</>
);
};
export default App;
You can view the full project and try out each example on GitHub:
React Performance Optimizations GitHub Project
By using these techniques, you can make your React apps run smoother and faster, even as they grow bigger.🙌
Check out the project and my profiles:
- Full Project Source Code: GitHub - React Performance Optimizations
- Personal Website: batuhanozturk.com
- GitHub Profile: github.com/fbatuhanr
- LinkedIn Profile: linkedin.com/in/-batuhan
Thanks for reading, and happy coding!