Lesson 8 of 12

67% Complete

Performance Optimization & Code Splitting

Learn React performance optimization techniques including memoization, code splitting, lazy loading, and profiling to build fast, efficient applications.

Overview

In this lesson, we'll help Ollie the Owl optimize a React component for better performance. You'll learn practical techniques to keep apps fast by avoiding unnecessary re-renders and reducing bundle size. We'll build a Contact List component that can display a large list of contacts, with a filter and an optional analytics panel, and we'll apply performance optimizations as we refine it.

By the end, you'll know how to:

  • Use React.memo to skip unnecessary re-renders of child components
  • Memoize expensive computations with useMemo
  • Stabilize function references with useCallback
  • Implement code splitting with React.lazy and Suspense for on-demand loading
  • Profile and identify performance bottlenecks, plus techniques like list virtualization for rendering lots of items efficiently

Sections overview:

  1. Preventing re-renders with memo
  2. Memoizing expensive calculations (useMemo)
  3. Stabilizing callbacks (useCallback)
  4. Code splitting and lazy loading
  5. Profiling and list virtualization basics
  6. Best practices & common pitfalls
  7. Recap

Step-by-step: build it right

1. Preventing unnecessary re-renders with memo

Let's start with a simple ContactList component that renders a list of contacts. Each contact is rendered by a child component ContactItem. We want to ensure that if the list re-renders (for example, due to a parent state change), each ContactItem doesn't re-render needlessly when its data hasn't changed. React's memo can help here by memoizing the output of a component unless its props change.

Initial ContactList without optimization: Every time the ContactList's parent re-renders or its state updates, all ContactItem components will re-render too (even if the contact data is the same). This can be inefficient for a long list. Let's introduce React.memo for ContactItem:

Loading syntax highlighting...

We wrap ContactItem in React.memo(...). Now ContactItem will skip re-rendering if its props (contact in this case) are shallowly equal to last render. In other words, as long as the same contact object is passed and hasn't changed, React can reuse the previous rendering of that ContactItem. This means if ContactList's parent re-renders (say, toggling a theme or some state not related to contacts), each ContactItem can remain as-is, avoiding extra work.

However, one thing to watch out for: if we generate new objects for props each time, memoization won't help. For example, passing a new object or function prop on every render will defeat memo because the prop isn't "==" to the previous one. We'll address this by memoizing values and callbacks next.

2. Memoizing expensive calculations with useMemo

In our ContactList, suppose we allow filtering the contacts by a search term. Filtering a large list can be computationally expensive if done on every render. React will normally recompute the filtered list every time the component renders (even if the data or filter hasn't changed). To avoid repeating this work unnecessarily, we can use useMemo to cache the filtered results until the dependencies (the list or search query) actually change.

Let's add a filter input to our ContactList and use useMemo to optimize the filtering:

Loading syntax highlighting...

Here, useMemo wraps the filtering logic. We provide a function that returns the filtered list, and [contacts, filter] as dependencies. React will run the filter function on the initial render and whenever contacts or filter changes; if neither has changed since the last render, it reuses the previous result instead of filtering again. This prevents doing a costly filter on every keystroke or every unrelated re-render. As the React docs note, "useMemo caches a calculation result between re-renders until its dependencies change."

Important: useMemo is a performance hint, not a guarantee. It doesn't make the first render faster; it just skips recalculation on later renders when possible. Always ensure your code works without useMemo first – then add it to optimize. If your app doesn't have noticeable slowdowns from a calculation, you might not need useMemo at all.

3. Stabilizing function references with useCallback

Now our ContactList filters efficiently and ContactItem is memoized. But there's another subtle cause of re-renders: functions. In JavaScript, function expressions are new objects every time they're created. If we pass a function prop to a memoized child, the child will still re-render because the function prop is a new reference on each render. For instance, imagine our ContactItem had a "select" button that calls an onSelect callback from props. If we define that callback inline in the parent, it would create a new function on every render – making ContactItem think its props changed each time!

To fix this, we use useCallback to memoize the function definition between renders. This way, as long as its dependencies don't change, we get the same function object and the memoized child can skip re-rendering.

Let's say we add a prop to ContactItem for when a contact is selected:

Loading syntax highlighting...

And in ContactList, we'll define handleSelect using useCallback:

Loading syntax highlighting...

We used useCallback to define handleSelect. Because we passed an empty dependency array [], this callback will be created once and stay the same across re-renders (it doesn't depend on any props/state that change). Now, ContactItem receives the same onSelect function each time, so if the contact data is also unchanged, the React.memo check will indicate the props are equal and skip re-rendering.

In summary, useCallback keeps functions "referentially stable" between renders. Use it when passing callbacks to optimized children (or to useEffect dependencies) to prevent unnecessary updates. Just like useMemo, useCallback is purely an optimization; if your code's correctness relies on it, fix the underlying issue first.

4. Code splitting and lazy loading

So far, we've optimized rendering and computation. Another big aspect of performance is bundle size – the amount of JavaScript users have to download and parse initially. If our component (or some feature of it) pulls in a heavy library or a rarely-used part of the UI, it can bloat the bundle. Code splitting allows us to load that code only when needed, speeding up initial load.

Imagine our ContactList has an "Analytics" panel that uses a large charting library to visualize contact data. We don't want to include that code unless the user actually opens the analytics. We can lazy-load the Analytics component using React.lazy and Suspense.

Loading syntax highlighting...

In the code above, React.lazy wraps a dynamic import() of the ContactAnalytics module. This tells React to split that code into a separate chunk and only load it when we attempt to render <ContactAnalytics />. We also wrap the usage in a <Suspense> component with a fallback UI – this is required to handle the loading state while the code is being fetched. Here we simply show a "Loading…" message; in a real app you might show a spinner or skeleton UI.

Result: The initial bundle for ContactList no longer includes the analytics code. When the user clicks "Show Analytics", React will load the chunk containing ContactAnalytics, then render it. This reduces initial load time since users who never open analytics won't pay the cost for it upfront. Code splitting is typically very beneficial for large routes or components that users may not always need.

⚠️ Always use <Suspense> when lazy-loading.

5. Profiling and list virtualization

With memoization and code splitting, our ContactList is much faster and more efficient. But how do we verify these improvements and catch other bottlenecks? This is where profiling comes in. React Developer Tools includes a Profiler that measures each component's render time and can highlight frequent re-renders. By profiling, you might discover, for example, that a certain component re-renders too often or an update is blocking the main thread. Always profile real user interactions to identify actual performance issues before prematurely optimizing. Use the Profiler to record when you type in the filter or toggle the analytics panel, and observe if any component's render stands out as slow or repeated. This data guides you on where to apply React.memo or other optimizations (remember, don't memoize everything by default – target the real hotspots).

Another technique for performance is list virtualization. If your contact list had thousands of items, even rendering them once can be expensive. Virtualization (or "windowing") means rendering only the items visible on screen, and as the user scrolls, recycling DOM nodes for new items entering view. Libraries like react-window make this easy: instead of a massive <div> of all items, you render, say, 10 at a time in a fixed-height container, and react-window will efficiently swap out the items as you scroll. This drastically reduces DOM nodes and render work for huge lists.

For example, using FixedSizeList from react-window:

Loading syntax highlighting...

This would only render enough ContactItem rows to fill a 600px tall container (plus a little buffer), recycling them as the user scrolls. The style prop is used to position each item correctly. With virtualization, even 10,000 contacts can scroll smoothly because the browser isn't trying to render 10,000 DOM nodes at once.

Virtualization is a more advanced optimization and introduces complexity (and is overkill for small lists), but it's essential for truly large collections. Always consider the trade-off: sometimes simpler pagination or limiting list size is sufficient.

Lastly, understanding React's reconciliation process can help you write performant components. React updates the DOM by diffing the virtual DOM trees – it makes assumptions like "elements of different types produce different trees" (so it will remount instead of update) and uses keys to match list items between renders. The key prop is crucial in lists: it helps React identify which items changed or moved so that it only updates those, rather than re-rendering the whole list out of order. Always use stable, unique keys for list items. This ensures React's reconciliation is efficient and keeps component state from getting accidentally reset during reorders. (For example, if you omit keys or use array indices that change, React might misidentify items and do extra work or lose element state.)

6. Best practices & common mistakes – Avoid dumb bugs

Performance optimizations are powerful, but they can introduce subtle bugs or complexity if used incorrectly. Here are some common pitfalls to avoid:

  • Don't mutate state or props: Changing objects or arrays in place and then using them in useMemo or as props can confuse React. Always make new copies or use state setters. Mutating can lead to missed re-renders or incorrect memo results.
  • Missing dependencies: When using useMemo or useCallback, make sure all reactive values used inside are listed in the dependency array. Forgetting one can cause stale values or missed updates. (Your linter/TypeScript should help catch this.) Conversely, avoid listing things that don't need to be dependencies, as that will break the caching.
  • Overusing memoization: Adding React.memo or useMemo/useCallback everywhere can make code harder to read and even slower in some cases (because computing the memo itself has a cost). Use them judiciously, primarily for expensive operations or to stabilize props for child components. If a component only ever gets small props or the app is not rendering large lists, you might skip memo altogether. Profile first, optimize where it matters.
  • Not wrapping lazy components: As mentioned, forgetting to wrap a lazy-loaded component in <Suspense> with a fallback UI will cause runtime errors. Always test your lazy-loading to ensure the fallback shows and the component loads as expected.
  • Using array index as key: For list items, using the index of the item in the array as the key can lead to issues if the list is reordered or filtered, since keys might then refer to different items. This can disrupt React's ability to preserve component state and efficiently update the list. Use stable IDs for keys whenever possible.
  • Blocking the main thread: Avoid doing very heavy work in the render path. If you have extremely expensive calculations that can't be memoized or split out, consider moving them to a web worker or using useTransition (if appropriate) to defer updates. Also keep an eye on third-party libraries – a large grid or chart library might need its own performance tuning (virtualization, throttling, etc.).

⚠️ Profile before and after changes.

7. Recap

You've now applied several React performance optimization techniques to our component library:

  • React.memo: Wrapped components like ContactItem to prevent re-rendering when props haven't changed, skipping work on unnecessary updates.
  • useMemo: Cached expensive calculations (like filtering a list) so they run only when their inputs change, avoiding repetitive heavy computations on each render.
  • useCallback: Kept callback functions stable across renders, so memoized children aren't tripped up by new function props every time.
  • Code splitting with React.lazy: Split out large, rarely-used components (e.g. analytics panel) to reduce initial bundle size, loading them on demand. Used <Suspense> with fallbacks to handle loading states.
  • Bundle analysis & profiling: Learned to analyze bundle size (with tools like webpack-bundle-analyzer) to find big dependencies, and used React Profiler to find which components re-render often or take long, guiding targeted optimizations.
  • List virtualization: Discussed rendering large lists efficiently by windowing – only rendering what's visible – using libraries like react-window to keep the UI snappy even with thousands of items.
  • React reconciliation insights: Recognized how React updates the DOM by diffing trees, the importance of keys in lists to help React identify changed elements, and that avoiding needless re-renders (by memoization or proper key usage) helps React do less work.

With these techniques, Ollie's component library can scale to complex, data-heavy UI without getting sluggish. You can now confidently apply memoization, lazy loading, and other optimizations to build fast, efficient React applications. Happy coding, and remember – keep it performant, but keep it readable too!


Next: Concurrent Mode and Suspense →