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: import and export.
  • 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:

  1. 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.
  2. 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: count and text.
  • DisplayMessage is a child component that receives message and count as props.
  • Crucially, DisplayMessage has a console.log statement so we can see when it renders.

Now, open your browser’s developer console.

Challenge:

  1. Click the “Increment Count” button. What happens in the console?
  2. 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:

  1. Click the “Increment Count” button. What happens in the console?
  2. Type something into the text input. What happens in the console now?

Observation:

  • When you click “Increment Count”, DisplayMessage still renders. Why? Because its count prop is changing. React.memo correctly detects this change and allows the re-render.
  • When you type in the text input, App re-renders because its text state changes. However, DisplayMessage’s message and count props do not change. React.memo performs its shallow comparison, sees no prop changes, and prevents DisplayMessage from 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.memo doesn’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:

  1. Type something into the text input. Observe the console. Does DisplayMessage still 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 of handleChildButtonClick.
  • The [] (empty array) is the dependency array. It tells useCallback that 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 across App renders.
  • Now, when App re-renders due to text changing, handleChildButtonClick is not recreated. React.memo on DisplayMessage sees that onButtonClick is the same function reference as before, and correctly prevents DisplayMessage from re-rendering!

Challenge Revisited (again!):

  1. Type something into the text input. Observe the console. Does DisplayMessage re-render now?
  2. Click the “Increment Count” button. What happens? Why?

Observation:

  • When you type in the text input, DisplayMessage no longer re-renders. Success!
  • When you click “Increment Count”, DisplayMessage still renders. This is expected because its count prop 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 useEffect or useMemo, 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:

  1. 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:

  1. Type something into the text input. Observe the console. Do you see “Calculating factorial…”?
  2. Click the “Increment Count” button. What happens?

Observation:

  • When you type in the text input, App re-renders, but memoizedFactorial is not re-calculated because count (its dependency) hasn’t changed. You will not see “Calculating factorial…”.
  • When you click “Increment Count”, count changes, so useMemo re-runs calculateFactorial, and you will see “Calculating factorial…”. Also, DisplayMessage re-renders because count and factorialValue props 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:

  1. const LazyHeavyComponent = lazy(() => import('./HeavyComponent'));:
    • React.lazy() takes a function that returns a Promise.
    • The import('./HeavyComponent') is a dynamic import. This tells your bundler (Vite, Webpack, etc.) to put HeavyComponent.jsx and its dependencies into a separate JavaScript file (a “chunk”) that will only be loaded when LazyHeavyComponent is actually rendered.
  2. <Suspense fallback={<div>Loading Heavy Component...</div>}>:
    • Suspense is a component that lets you “wait” for some code to load and display a fallback UI (like a loading spinner) while it’s waiting.
    • fallback prop is required and accepts any React elements that you want to display while the lazy-loaded component is being downloaded.
    • When showHeavyComponent is true, React tries to render LazyHeavyComponent. If the component’s code hasn’t been downloaded yet, Suspense catches the “suspension” and renders the fallback until the code is ready.

Challenge:

  1. Open your browser’s developer tools (usually F12), go to the “Network” tab, and filter by “JS”.
  2. Refresh the page. Notice the initial JavaScript files loaded. You should see index.js (or similar) but not HeavyComponent.js (or a chunk related to it).
  3. 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 like assets/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, HeavyComponent renders, and its useEffect log 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 with React.lazy and Suspense for 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.

flowchart TD A[index.js] --> B(App Component) B --> C(Home Component) B --> D(About Component) B --> E(Dashboard Component) subgraph Initial Load A C end subgraph Lazy-Loaded Chunks D -->|on /about route| D_Chunk(about.js) E -->|on /dashboard route| E_Chunk(dashboard.js) E -->|heavy UI component| F_Chunk(chart.js) end style A fill:#f9f,stroke:#333,stroke-width:2px style C fill:#ccf,stroke:#333,stroke-width:2px style D_Chunk fill:#cfc,stroke:#333,stroke-width:2px style E_Chunk fill:#cfc,stroke:#333,stroke-width:2px style F_Chunk fill:#cfc,stroke:#333,stroke-width:2px

Explanation of the Diagram:

  • index.js (Main Bundle) and Home Component: These are part of the initial load. When a user first visits your app, these are downloaded immediately.
  • About Component and Dashboard Component: These are defined as separate entry points, likely using React.lazy in your code.
  • on /about route and on /dashboard route: These labels on the arrows indicate that the about.js and dashboard.js chunks 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:

  1. Two Routes: Implement two simple routes using react-router-dom (e.g., / for Home, /dashboard).
  2. Lazy Load Dashboard: The /dashboard route component should be lazy-loaded using React.lazy and wrapped in Suspense with a fallback.
  3. 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.
  4. 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.memo and receive a callback function from its parent (the Home page) that is also memoized using useCallback.
  5. Observe Renders: Use console.log statements 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.

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 App with BrowserRouter from react-router-dom.
  • Use Routes and Route components for defining your paths.
  • The Suspense component can wrap your Routes or individual Route elements.

What to Observe/Learn:

  • How React.lazy and Suspense work together with a router to create distinct code chunks.
  • The effectiveness of React.memo and useCallback in 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:

  1. Over-Memoization (Premature Optimization):

    • Pitfall: Wrapping every component or calculation with memo, useCallback, or useMemo without 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.
  2. Incorrect Dependency Arrays:

    • Pitfall: Forgetting to include a dependency in useCallback or useMemo’s dependency array, or including unstable references (e.g., objects/arrays created inline). This can lead to stale closures (functions using outdated values) or memo not 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 inside useCallback or useMemo that come from the component’s scope are correctly listed as dependencies.
  3. Suspense Fallback Issues:

    • Pitfall: Not providing a fallback prop to Suspense, leading to errors. Or, providing a very basic fallback that creates a poor user experience (e.g., a blank screen).
    • Troubleshooting: Always ensure your Suspense component has a meaningful fallback. 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.
  4. Server-Side Rendering (SSR) and Lazy Loading:

    • Pitfall: React.lazy works client-side. If you’re using SSR, the server won’t know how to resolve the dynamic import() 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 called loadable) 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 dynamic import) or integrate loadable-components correctly.

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 dynamic import() 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

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.