Welcome back, future React maestro! In the previous chapters, you’ve mastered the fundamentals of building interactive UIs with React. You can create components, manage state, handle user input, and even fetch data asynchronously. That’s fantastic! But as your applications grow, you might start noticing them feeling a little sluggish. Ever wonder why some websites load instantly while others take an eternity? Often, it comes down to performance optimization.
This chapter is your deep dive into making your React applications blazingly fast and wonderfully smooth. We’ll uncover three powerful techniques: memoization, lazy loading, and code splitting. These aren’t just fancy terms; they are essential tools in a professional React developer’s toolkit, helping you deliver exceptional user experiences, improve SEO, and reduce bounce rates.
By the end of this chapter, you’ll not only understand what these techniques are but also why they’re crucial and how to implement them effectively in your projects, following modern best practices as of early 2026. Get ready to supercharge your React apps!
Prerequisites
Before we jump in, make sure you’re comfortable with:
- React Components: Functional components, props, and state.
- React Hooks:
useState,useEffect. - Basic JavaScript Modules:
importandexport. - Understanding of the React Rendering Process: How components re-render when state or props change.
The Need for Speed: Why Optimize?
Imagine opening a web page and waiting for what feels like an age for content to appear. Frustrating, right? Slow loading times and unresponsive UIs can drive users away. Performance optimization isn’t just a “nice-to-have”; it’s a critical aspect of modern web development.
In React, performance issues often stem from two main culprits:
- Unnecessary Re-renders: When a component re-renders, React re-executes its function body to determine what to display. If this happens too often, especially for complex components or components deep in the component tree, it can lead to noticeable delays and a “laggy” feel.
- Large Initial Bundle Sizes: As your application grows, so does the JavaScript file (or “bundle”) that the browser needs to download. A large bundle means longer download times, particularly on slower networks, delaying when your users can actually interact with your app.
Let’s tackle these problems head-on!
Memoization: Preventing Unnecessary Re-renders
Memoization is an optimization technique used to speed up computer programs by caching the results of expensive function calls and returning the cached result when the same inputs occur again. In React, this means preventing components or calculations from re-running if their inputs (props or dependencies) haven’t changed.
Think of it like this: if you’ve already calculated 2 + 2 = 4, why calculate it again every time someone asks “what’s 2 + 2?” You just remember the answer!
React provides three main tools for memoization: React.memo, useMemo, and useCallback.
React.memo: Memoizing Functional Components
React.memo is a higher-order component (HOC) that you can wrap around a functional component. It tells React, “Hey, only re-render this component if its props have actually changed.”
How it works: React.memo performs a shallow comparison of the component’s props. If the new props are the same as the old props (referentially equal for objects/arrays, value equal for primitives), React skips rendering the component and reuses the last rendered result.
Let’s see it in action.
Step 1: Set up a Basic React App
If you don’t have one, create a new React project using Vite (a popular, fast build tool as of 2026):
npm create vite@latest my-react-perf-app -- --template react
cd my-react-perf-app
npm install
npm run dev
Step 2: Create a Simple, Unoptimized Component
Open src/App.jsx. Let’s create a parent App component and a child DisplayMessage component.
First, replace the content of src/App.jsx with this:
// src/App.jsx
import { useState } from 'react';
import './App.css';
// Our child component that displays a message
function DisplayMessage({ message, count }) {
console.log('DisplayMessage component rendered!'); // We'll use this to track renders
return (
<div className="card">
<p>{message}</p>
<p>Count: {count}</p>
</div>
);
}
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const handleIncrement = () => {
setCount(c => c + 1);
};
const handleTextChange = (e) => {
setText(e.target.value);
};
return (
<>
<h1>Performance Demo</h1>
<section>
<h2>Parent Component State</h2>
<button onClick={handleIncrement}>Increment Count: {count}</button>
<input type="text" value={text} onChange={handleTextChange} placeholder="Type something..." />
<p>Current text: {text}</p>
</section>
<section>
<h2>Child Component (Unoptimized)</h2>
<DisplayMessage message="Hello from child!" count={count} />
</section>
</>
);
}
export default App;
Explanation:
- We have
App, which manages two pieces of state:countandtext. DisplayMessageis a child component that receivesmessageandcountas props.- Crucially,
DisplayMessagehas aconsole.logstatement so we can see when it renders.
Now, open your browser’s developer console.
Challenge:
- Click the “Increment Count” button. What happens in the console?
- Type something into the text input. What happens in the console?
You should see DisplayMessage component rendered! logged every time you click the button or type in the input.
Why is this happening?
When the App component’s state (count or text) changes, App re-renders. Because DisplayMessage is a child of App, React, by default, re-renders DisplayMessage too, even if the message prop (which is a static string) hasn’t changed. The count prop does change when you increment, but the message prop doesn’t change when you type text. This is an unnecessary re-render for DisplayMessage when only text changes.
Step 3: Optimize with React.memo
Let’s tell React that DisplayMessage only needs to re-render if its props (message or count) actually change.
Modify src/App.jsx by wrapping DisplayMessage with React.memo:
// src/App.jsx
import { useState, memo } from 'react'; // Import memo
import './App.css';
// Wrap DisplayMessage with React.memo
const DisplayMessage = memo(function DisplayMessage({ message, count }) {
console.log('DisplayMessage component rendered!');
return (
<div className="card">
<p>{message}</p>
<p>Count: {count}</p>
</div>
);
}); // Don't forget the closing parenthesis and semicolon!
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const handleIncrement = () => {
setCount(c => c + 1);
};
const handleTextChange = (e) => {
setText(e.target.value);
};
return (
<>
<h1>Performance Demo</h1>
<section>
<h2>Parent Component State</h2>
<button onClick={handleIncrement}>Increment Count: {count}</button>
<input type="text" value={text} onChange={handleTextChange} placeholder="Type something..." />
<p>Current text: {text}</p>
</section>
<section>
<h2>Child Component (Optimized with React.memo)</h2>
{/* We pass the message prop, which is static */}
<DisplayMessage message="Hello from child!" count={count} />
</section>
</>
);
}
export default App;
Challenge Revisited:
- Click the “Increment Count” button. What happens in the console?
- Type something into the text input. What happens in the console now?
Observation:
- When you click “Increment Count”,
DisplayMessagestill renders. Why? Because itscountprop is changing.React.memocorrectly detects this change and allows the re-render. - When you type in the text input,
Appre-renders because itstextstate changes. However,DisplayMessage’smessageandcountprops do not change.React.memoperforms its shallow comparison, sees no prop changes, and preventsDisplayMessagefrom re-rendering! Victory!
This is a subtle but powerful optimization. For components that receive many props, some of which are static or change infrequently, React.memo can save a lot of rendering work.
When to use React.memo:
- Your component often re-renders with the same props.
- Your component is relatively “expensive” to render (e.g., it contains complex calculations or many child components).
- You are confident that a shallow comparison of props is sufficient.
When not to use React.memo:
- Your component re-renders frequently with different props. The overhead of the memoization check might outweigh the benefits.
- Your component is very small and simple.
- You need a deep comparison of props (which
React.memodoesn’t do by default, but you can provide a custom comparison function as a second argument).
useCallback: Memoizing Functions
What if you pass a function as a prop to a memo-ized child component? Let’s explore that.
Step 1: Add a function prop to DisplayMessage
Modify src/App.jsx again. Let’s add a button inside DisplayMessage that calls a function passed from App.
// src/App.jsx
import { useState, memo, useCallback } from 'react'; // Import useCallback
import './App.css';
const DisplayMessage = memo(function DisplayMessage({ message, count, onButtonClick }) {
console.log('DisplayMessage component rendered!');
return (
<div className="card">
<p>{message}</p>
<p>Count: {count}</p>
<button onClick={onButtonClick}>Click Me (from child)</button> {/* New button */}
</div>
);
});
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const [childClicks, setChildClicks] = useState(0); // New state for child clicks
const handleIncrement = () => {
setCount(c => c + 1);
};
const handleTextChange = (e) => {
setText(e.target.value);
};
// This function is created anew on every render of App
const handleChildButtonClick = () => {
console.log('Child button clicked!');
setChildClicks(prev => prev + 1);
};
return (
<>
<h1>Performance Demo</h1>
<section>
<h2>Parent Component State</h2>
<button onClick={handleIncrement}>Increment Count: {count}</button>
<input type="text" value={text} onChange={handleTextChange} placeholder="Type something..." />
<p>Current text: {text}</p>
<p>Child button clicks: {childClicks}</p> {/* Display child clicks */}
</section>
<section>
<h2>Child Component (with function prop)</h2>
<DisplayMessage message="Hello from child!" count={count} onButtonClick={handleChildButtonClick} />
</section>
</>
);
}
export default App;
Challenge:
- Type something into the text input. Observe the console. Does
DisplayMessagestill re-render?
Observation:
Even though DisplayMessage is wrapped in React.memo, it still re-renders when you type in the text input! Why?
Because handleChildButtonClick is defined inside the App component. Every time App re-renders (e.g., when text changes), a new function instance of handleChildButtonClick is created. Even if the function’s code is identical, its memory address is different.
When React.memo performs its shallow comparison of props for DisplayMessage, it sees that the onButtonClick prop (which is handleChildButtonClick) is a new function reference compared to the previous render. Since the prop has “changed” (referentially), React.memo decides to re-render DisplayMessage.
This is where useCallback comes to the rescue!
Step 2: Optimize with useCallback
useCallback is a React Hook that returns a memoized version of the callback function. It only changes if one of its dependencies has changed.
Wrap handleChildButtonClick with useCallback:
// src/App.jsx
import { useState, memo, useCallback } from 'react';
import './App.css';
const DisplayMessage = memo(function DisplayMessage({ message, count, onButtonClick }) {
console.log('DisplayMessage component rendered!');
return (
<div className="card">
<p>{message}</p>
<p>Count: {count}</p>
<button onClick={onButtonClick}>Click Me (from child)</button>
</div>
);
});
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const [childClicks, setChildClicks] = useState(0);
const handleIncrement = () => {
setCount(c => c + 1);
};
const handleTextChange = (e) => {
setText(e.target.value);
};
// Memoize handleChildButtonClick using useCallback
const handleChildButtonClick = useCallback(() => {
console.log('Child button clicked!');
setChildClicks(prev => prev + 1);
}, []); // Empty dependency array means this function is created once and never changes
return (
<>
<h1>Performance Demo</h1>
<section>
<h2>Parent Component State</h2>
<button onClick={handleIncrement}>Increment Count: {count}</button>
<input type="text" value={text} onChange={handleTextChange} placeholder="Type something..." />
<p>Current text: {text}</p>
<p>Child button clicks: {childClicks}</p>
</section>
<section>
<h2>Child Component (with memoized function prop)</h2>
<DisplayMessage message="Hello from child!" count={count} onButtonClick={handleChildButtonClick} />
</section>
</>
);
}
export default App;
Explanation of useCallback:
useCallback(() => { ... }, [])creates a memoized version ofhandleChildButtonClick.- The
[](empty array) is the dependency array. It tellsuseCallbackthat this function should only be re-created if any of the values in this array change. Since it’s empty, the function reference will remain stable acrossApprenders. - Now, when
Appre-renders due totextchanging,handleChildButtonClickis not recreated.React.memoonDisplayMessagesees thatonButtonClickis the same function reference as before, and correctly preventsDisplayMessagefrom re-rendering!
Challenge Revisited (again!):
- Type something into the text input. Observe the console. Does
DisplayMessagere-render now? - Click the “Increment Count” button. What happens? Why?
Observation:
- When you type in the text input,
DisplayMessageno longer re-renders. Success! - When you click “Increment Count”,
DisplayMessagestill renders. This is expected because itscountprop does change.
When to use useCallback:
- When passing callback functions to
memo-ized child components to prevent unnecessary re-renders of the child. - When a function is a dependency of another Hook, like
useEffectoruseMemo, and you want to prevent that Hook from re-running too often.
Important Note on Dependency Arrays:
If your useCallback function uses values from its parent component’s scope (e.g., state or props), those values must be included in the dependency array.
For example, if handleChildButtonClick needed to access count:
const handleChildButtonClick = useCallback(() => {
console.log('Child button clicked! Count was:', count); // Accessing count
setChildClicks(prev => prev + 1);
}, [count]); // 'count' must be in the dependency array
If count was in the dependency array, handleChildButtonClick would be re-created when count changes. This is the correct behavior because the function’s logic now depends on count.
useMemo: Memoizing Expensive Calculations
While useCallback memoizes functions, useMemo memoizes the result of an expensive calculation.
How it works: useMemo takes a function that computes a value and a dependency array. It only re-executes the function and re-computes the value if one of its dependencies changes. Otherwise, it returns the previously computed value.
Let’s imagine DisplayMessage needs to perform a heavy calculation based on its count prop.
Step 1: Add an Expensive Calculation
Modify src/App.jsx to include an artificial “expensive” calculation inside DisplayMessage.
// src/App.jsx
import { useState, memo, useCallback } from 'react';
import './App.css';
// A helper function to simulate an expensive calculation
const calculateFactorial = (n) => {
console.log(`Calculating factorial for ${n}...`);
if (n < 0) return -1;
if (n === 0) return 1;
let result = 1;
for (let i = 1; i <= n; i++) {
result *= i;
}
return result;
};
const DisplayMessage = memo(function DisplayMessage({ message, count, onButtonClick }) {
console.log('DisplayMessage component rendered!');
// This calculation runs on every render of DisplayMessage
const factorial = calculateFactorial(count);
return (
<div className="card">
<p>{message}</p>
<p>Count: {count}</p>
<p>Factorial of Count: {factorial}</p> {/* Display factorial */}
<button onClick={onButtonClick}>Click Me (from child)</button>
</div>
);
});
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const [childClicks, setChildClicks] = useState(0);
const handleIncrement = () => {
setCount(c => c + 1);
};
const handleTextChange = (e) => {
setText(e.target.value);
};
const handleChildButtonClick = useCallback(() => {
console.log('Child button clicked!');
setChildClicks(prev => prev + 1);
}, []);
return (
<>
<h1>Performance Demo</h1>
<section>
<h2>Parent Component State</h2>
<button onClick={handleIncrement}>Increment Count: {count}</button>
<input type="text" value={text} onChange={handleTextChange} placeholder="Type something..." />
<p>Current text: {text}</p>
<p>Child button clicks: {childClicks}</p>
</section>
<section>
<h2>Child Component (with expensive calculation)</h2>
<DisplayMessage message="Hello from child!" count={count} onButtonClick={handleChildButtonClick} />
</section>
</>
);
}
export default App;
Challenge:
- Type something into the text input. Observe the console. Do you see “Calculating factorial…”?
Observation:
Even though DisplayMessage doesn’t re-render (thanks to React.memo and useCallback), the calculateFactorial function is still being called if it were inside the DisplayMessage function body. In our current setup, calculateFactorial is called within DisplayMessage, but DisplayMessage itself isn’t re-rendering when text changes in App. So, calculateFactorial is not called when text changes.
However, calculateFactorial is called every time count changes. What if count changed frequently, but the factorial calculation was really expensive? We want to avoid re-calculating if count hasn’t changed.
Let’s move the factorial calculation to the App component and pass it down, showing useMemo in action.
Step 2: Optimize with useMemo
We’ll move calculateFactorial outside App (as it’s a pure utility function) and then use useMemo inside App to memoize its result before passing it to DisplayMessage.
// src/App.jsx
import { useState, memo, useCallback, useMemo } from 'react'; // Import useMemo
import './App.css';
// A helper function to simulate an expensive calculation (outside component)
const calculateFactorial = (n) => {
console.log(`Calculating factorial for ${n}...`);
if (n < 0) return -1;
if (n === 0) return 1;
// Artificially slow down to demonstrate performance impact
let result = 1;
for (let i = 1; i <= n; i++) {
result *= i;
}
return result;
};
const DisplayMessage = memo(function DisplayMessage({ message, count, factorialValue, onButtonClick }) { // Added factorialValue prop
console.log('DisplayMessage component rendered!');
return (
<div className="card">
<p>{message}</p>
<p>Count: {count}</p>
<p>Factorial of Count (memoized): {factorialValue}</p> {/* Display memoized factorial */}
<button onClick={onButtonClick}>Click Me (from child)</button>
</div>
);
});
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const [childClicks, setChildClicks] = useState(0);
const handleIncrement = () => {
setCount(c => c + 1);
};
const handleTextChange = (e) => {
setText(e.target.value);
};
const handleChildButtonClick = useCallback(() => {
console.log('Child button clicked!');
setChildClicks(prev => prev + 1);
}, []);
// Memoize the result of calculateFactorial
const memoizedFactorial = useMemo(() => {
return calculateFactorial(count);
}, [count]); // Re-calculate only when 'count' changes
return (
<>
<h1>Performance Demo</h1>
<section>
<h2>Parent Component State</h2>
<button onClick={handleIncrement}>Increment Count: {count}</button>
<input type="text" value={text} onChange={handleTextChange} placeholder="Type something..." />
<p>Current text: {text}</p>
<p>Child button clicks: {childClicks}</p>
</section>
<section>
<h2>Child Component (with memoized calculation)</h2>
<DisplayMessage
message="Hello from child!"
count={count}
factorialValue={memoizedFactorial} // Pass the memoized value
onButtonClick={handleChildButtonClick}
/>
</section>
</>
);
}
export default App;
Challenge:
- Type something into the text input. Observe the console. Do you see “Calculating factorial…”?
- Click the “Increment Count” button. What happens?
Observation:
- When you type in the text input,
Appre-renders, butmemoizedFactorialis not re-calculated becausecount(its dependency) hasn’t changed. You will not see “Calculating factorial…”. - When you click “Increment Count”,
countchanges, souseMemore-runscalculateFactorial, and you will see “Calculating factorial…”. Also,DisplayMessagere-renders becausecountandfactorialValueprops have changed.
This demonstrates how useMemo effectively caches the result of an expensive calculation, preventing it from running unnecessarily on every render.
When to use useMemo:
- When you have a computation that is expensive (takes noticeable time) and its result is only dependent on specific values.
- When you need to memoize an object or array that is passed as a prop to a
memo-ized child component, to prevent the child from re-rendering due to referential inequality.
A Word of Caution on Memoization:
Memoization comes with its own overhead (memory for caching, comparison checks). Don’t apply React.memo, useCallback, or useMemo everywhere! It’s a form of premature optimization if used indiscriminately. Only apply these when you identify an actual performance bottleneck through profiling (e.g., using React DevTools Profiler).
Lazy Loading and Code Splitting: Shrinking Your Bundle Size
While memoization optimizes runtime performance by reducing re-renders, lazy loading and code splitting optimize initial load time by reducing the amount of JavaScript the browser needs to download upfront.
Imagine your application is a huge book. Instead of giving the user the entire book at once, you only give them the first chapter. If they want to read Chapter 17, you deliver just that chapter when they ask for it. That’s essentially what lazy loading and code splitting do for your code.
Code splitting is the process of dividing your application’s JavaScript bundle into smaller “chunks.” Lazy loading is the technique of loading these chunks only when they are needed (e.g., when a user navigates to a specific route or interacts with a particular UI element).
React provides React.lazy and Suspense to enable lazy loading components. Your bundler (like Vite, Webpack, or Rollup) handles the actual code splitting.
React.lazy: Dynamically Loading Components
React.lazy lets you render a dynamic import() as a regular component.
Step 1: Create a “Heavy” Component
Let’s create a new component that we’ll pretend is very large or only needed on certain pages.
Create a new file src/HeavyComponent.jsx:
// src/HeavyComponent.jsx
import { useEffect } from 'react';
function HeavyComponent() {
// Simulate some heavy initialization or data fetching
useEffect(() => {
console.log('HeavyComponent mounted and initialized!');
// Imagine this component imports a large library or does complex setup
}, []);
return (
<div style={{ border: '1px solid blue', padding: '20px', margin: '20px', backgroundColor: '#e0f7fa' }}>
<h3>I am a Heavy Component!</h3>
<p>I would typically contain a lot of code or complex logic.</p>
<p>Notice when my console log appears!</p>
</div>
);
}
export default HeavyComponent;
Step 2: Implement Lazy Loading with React.lazy and Suspense
Now, let’s modify src/App.jsx to lazy load HeavyComponent.
// src/App.jsx
import { useState, memo, useCallback, useMemo, lazy, Suspense } from 'react'; // Import lazy and Suspense
import './App.css';
// ... (calculateFactorial, DisplayMessage components remain the same) ...
const calculateFactorial = (n) => {
console.log(`Calculating factorial for ${n}...`);
if (n < 0) return -1;
if (n === 0) return 1;
let result = 1;
for (let i = 1; i <= n; i++) {
result *= i;
}
return result;
};
const DisplayMessage = memo(function DisplayMessage({ message, count, factorialValue, onButtonClick }) {
console.log('DisplayMessage component rendered!');
return (
<div className="card">
<p>{message}</p>
<p>Count: {count}</p>
<p>Factorial of Count (memoized): {factorialValue}</p>
<button onClick={onButtonClick}>Click Me (from child)</button>
</div>
);
});
// Lazy load the HeavyComponent
const LazyHeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const [childClicks, setChildClicks] = useState(0);
const [showHeavyComponent, setShowHeavyComponent] = useState(false); // New state to control visibility
const handleIncrement = () => {
setCount(c => c + 1);
};
const handleTextChange = (e) => {
setText(e.target.value);
};
const handleChildButtonClick = useCallback(() => {
console.log('Child button clicked!');
setChildClicks(prev => prev + 1);
}, []);
const memoizedFactorial = useMemo(() => {
return calculateFactorial(count);
}, [count]);
const toggleHeavyComponent = () => { // New function to toggle visibility
setShowHeavyComponent(prev => !prev);
};
return (
<>
<h1>Performance Demo</h1>
<section>
<h2>Parent Component State</h2>
<button onClick={handleIncrement}>Increment Count: {count}</button>
<input type="text" value={text} onChange={handleTextChange} placeholder="Type something..." />
<p>Current text: {text}</p>
<p>Child button clicks: {childClicks}</p>
</section>
<section>
<h2>Child Component (with memoized calculation)</h2>
<DisplayMessage
message="Hello from child!"
count={count}
factorialValue={memoizedFactorial}
onButtonClick={handleChildButtonClick}
/>
</section>
<section>
<h2>Lazy Loading Example</h2>
<button onClick={toggleHeavyComponent}>
{showHeavyComponent ? 'Hide Heavy Component' : 'Show Heavy Component'}
</button>
{showHeavyComponent && (
<Suspense fallback={<div>Loading Heavy Component...</div>}>
<LazyHeavyComponent />
</Suspense>
)}
</section>
</>
);
}
export default App;
Explanation:
const LazyHeavyComponent = lazy(() => import('./HeavyComponent'));:React.lazy()takes a function that returns aPromise.- The
import('./HeavyComponent')is a dynamic import. This tells your bundler (Vite, Webpack, etc.) to putHeavyComponent.jsxand its dependencies into a separate JavaScript file (a “chunk”) that will only be loaded whenLazyHeavyComponentis actually rendered.
<Suspense fallback={<div>Loading Heavy Component...</div>}>:Suspenseis a component that lets you “wait” for some code to load and display a fallback UI (like a loading spinner) while it’s waiting.fallbackprop is required and accepts any React elements that you want to display while the lazy-loaded component is being downloaded.- When
showHeavyComponentis true, React tries to renderLazyHeavyComponent. If the component’s code hasn’t been downloaded yet,Suspensecatches the “suspension” and renders thefallbackuntil the code is ready.
Challenge:
- Open your browser’s developer tools (usually F12), go to the “Network” tab, and filter by “JS”.
- Refresh the page. Notice the initial JavaScript files loaded. You should see
index.js(or similar) but notHeavyComponent.js(or a chunk related to it). - Click the “Show Heavy Component” button. What happens in the Network tab? When does “Loading Heavy Component…” appear and disappear? When does “HeavyComponent mounted…” appear in the console?
Observation:
- Initially,
HeavyComponent.js(or a chunk likeassets/HeavyComponent-XXXX.js) is not loaded. - When you click “Show Heavy Component”, you’ll briefly see “Loading Heavy Component…” while the browser fetches the
HeavyComponent’s JavaScript chunk from the network. - Once downloaded,
HeavyComponentrenders, and itsuseEffectlog appears. - This demonstrates true on-demand loading, significantly reducing your initial bundle size and improving the first paint time for users who don’t immediately need that “heavy” part of your app.
Code Splitting Points
While React.lazy helps with component-level lazy loading, the principle of code splitting can be applied more broadly. Common places to implement code splitting include:
- Routes: This is the most common use case. Each major route (e.g.,
/dashboard,/settings,/admin) can be a separate code chunk. React Router (v6.x and later, as of 2026) works seamlessly withReact.lazyandSuspensefor route-based code splitting. - Large Components: Any component that is particularly large or used infrequently can be a candidate.
- Utility Libraries: If you have a large utility library that’s only used in one specific part of your application, you might dynamically import it.
Mermaid Diagram: Code Splitting in Action
Let’s visualize how a bundler might split your application’s code into chunks for lazy loading.
Explanation of the Diagram:
index.js(Main Bundle) andHome Component: These are part of the initial load. When a user first visits your app, these are downloaded immediately.About ComponentandDashboard Component: These are defined as separate entry points, likely usingReact.lazyin your code.on /about routeandon /dashboard route: These labels on the arrows indicate that theabout.jsanddashboard.jschunks are only downloaded when the user navigates to those specific routes.heavy UI component: This shows that even within a route, a particularly large or complex component (like a chart library) can be further split into its own chunk (chart.js) and lazy-loaded only when it’s needed within the dashboard.
This modular approach ensures users only download the code they currently need, leading to a much snappier initial experience.
Mini-Challenge: Combine Optimizations
Let’s put your new knowledge to the test!
Challenge:
Create a new React app or modify your existing one to demonstrate the combined power of these techniques:
- Two Routes: Implement two simple routes using
react-router-dom(e.g.,/for Home,/dashboard). - Lazy Load Dashboard: The
/dashboardroute component should be lazy-loaded usingReact.lazyand wrapped inSuspensewith a fallback. - Memoized Counter: On your Home page, create a counter with an increment button. Also, have a separate input field that changes some unrelated state on the Home page.
- Memoized Child (with Callback): The Home page should render a child component that displays the counter value. This child component must be memoized using
React.memoand receive a callback function from its parent (the Home page) that is also memoized usinguseCallback. - Observe Renders: Use
console.logstatements in your components to verify that:- The lazy-loaded dashboard component’s code is only fetched when you navigate to
/dashboard. - The memoized child on the Home page only re-renders when the counter (its relevant prop) changes, not when the unrelated input field’s state changes.
- The lazy-loaded dashboard component’s code is only fetched when you navigate to
Hint:
- Install
react-router-dom:npm install react-router-dom@^6.22.3(as of 2026-01-31, React Router v6 is the stable standard). - Remember to wrap your
AppwithBrowserRouterfromreact-router-dom. - Use
RoutesandRoutecomponents for defining your paths. - The
Suspensecomponent can wrap yourRoutesor individualRouteelements.
What to Observe/Learn:
- How
React.lazyandSuspensework together with a router to create distinct code chunks. - The effectiveness of
React.memoanduseCallbackin preventing unnecessary re-renders even in a multi-route application. - The network tab will be your best friend to see the code chunks being loaded.
Common Pitfalls & Troubleshooting
Even with powerful tools, it’s easy to stumble. Here are some common issues and how to tackle them:
Over-Memoization (Premature Optimization):
- Pitfall: Wrapping every component or calculation with
memo,useCallback, oruseMemowithout profiling. This adds overhead (memory for caching, comparison checks) that can sometimes be slower than just letting React re-render. - Troubleshooting: Always profile first! Use the React DevTools Profiler to identify actual bottlenecks. Look for components that take a long time to render or re-render excessively. Optimize only where it makes a measurable difference.
- Best Practice (2026): Start simple. Add memoization only when performance issues arise and you’ve identified the specific components or calculations causing them.
- Pitfall: Wrapping every component or calculation with
Incorrect Dependency Arrays:
- Pitfall: Forgetting to include a dependency in
useCallbackoruseMemo’s dependency array, or including unstable references (e.g., objects/arrays created inline). This can lead to stale closures (functions using outdated values) ormemonot working as expected. - Troubleshooting: React (especially in development mode) often warns you about missing dependencies. Pay attention to these warnings! If you pass an object or array created inline to a dependency array, it will always be a new reference, defeating memoization.
// Pitfall: `myObject` is a new reference on every render const memoizedValue = useMemo(() => expensiveFn(myObject), [myObject]); // Solution: Define `myObject` outside the component or memoize it too - Best Practice (2026): Let your linter (like ESLint with
eslint-plugin-react-hooks) guide you on dependency arrays. Ensure all values used insideuseCallbackoruseMemothat come from the component’s scope are correctly listed as dependencies.
- Pitfall: Forgetting to include a dependency in
SuspenseFallback Issues:- Pitfall: Not providing a
fallbackprop toSuspense, leading to errors. Or, providing a very basic fallback that creates a poor user experience (e.g., a blank screen). - Troubleshooting: Always ensure your
Suspensecomponent has a meaningfulfallback. Consider skeleton loaders or spinners that match your app’s design. - Best Practice (2026): Design thoughtful loading states. For more complex loading scenarios, especially when fetching data, consider libraries like TanStack Query (React Query) which offer more granular control over loading, error, and stale states.
- Pitfall: Not providing a
Server-Side Rendering (SSR) and Lazy Loading:
- Pitfall:
React.lazyworks client-side. If you’re using SSR, the server won’t know how to resolve the dynamicimport()statement, leading to errors or blank content on initial SSR render. - Troubleshooting: For SSR, you generally need a specialized library like
loadable-components(often just calledloadable) that can handle code splitting on both the server and client. Frameworks like Next.js or Remix have their own built-in solutions for this. - Best Practice (2026): If building an SSR application, leverage the framework’s built-in lazy loading mechanisms (e.g., Next.js
dynamicimport) or integrateloadable-componentscorrectly.
- Pitfall:
Summary
Phew! You’ve just equipped yourself with some serious performance superpowers. Let’s quickly recap what you’ve learned:
- The “Why”: Performance optimization is crucial for user experience, SEO, and application scalability.
- Memoization:
React.memo: A Higher-Order Component (HOC) to memoize functional components, preventing re-renders if props haven’t shallowly changed.useCallback: A Hook to memoize functions, ensuring their reference remains stable across renders, particularly useful when passing callbacks to memoized children.useMemo: A Hook to memoize the result of an expensive calculation, re-running only when its dependencies change.- Caution: Use memoization judiciously and only after profiling, avoiding premature optimization.
- Lazy Loading and Code Splitting:
- The “Why”: Reduces initial bundle size, leading to faster initial load times.
React.lazy: A function that lets you render a dynamicimport()as a regular component.Suspense: A component that lets you display a fallback UI while a lazy-loaded component (or other asynchronous operation) is loading.- Code Splitting: The underlying bundler feature that creates separate JavaScript chunks, enabled by dynamic
import(). Common splitting points include routes and large components.
- Best Practices: Profile your applications, use linters for dependency arrays, design good loading states, and be aware of SSR considerations for lazy loading.
You’re now ready to build not just functional, but also highly performant React applications!
What’s Next?
In the next chapter, we’ll shift our focus to Testing React Applications. Building robust, performant applications also means ensuring they work correctly and reliably. We’ll explore unit testing, integration testing, and end-to-end testing strategies, giving you the confidence to ship high-quality code.
References
- React.dev: Optimizing Performance
- React.dev:
memo - React.dev:
useCallback - React.dev:
useMemo - React.dev:
lazy - React.dev:
Suspense - React Router Dom v6 Documentation
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.