Optimizing Performance

React is fast out of the box, but as an app grows, unnecessary re-renders and repeated expensive calculations can start to show up as laggy typing, slow list scrolling, or dropped frames. Performance optimization in React is rarely about rewriting your app — it’s about giving React a few targeted hints so it can skip work it doesn’t need to redo. This lesson explains why components re-render in the first place, then walks through the main tools React gives you to avoid wasted work: React.memo, useMemo, useCallback, and code-splitting with lazy and Suspense.

Overview / How it works

Every time a component’s state or props change, React calls the component function again to figure out what the UI should look like now. This is the render phase — it produces a new tree of React elements (the Virtual DOM). React then compares that new tree to the previous one in the reconciliation phase, figuring out the minimal set of changes needed. Finally, in the commit phase, React applies those changes to the real DOM. State updates trigger this whole cycle because React has no other way to know your UI needs to change — a useState setter or a useReducer dispatch is the signal that says “something changed, please re-render.”

Here’s the part that surprises a lot of developers: by default, when a component re-renders, every child component in that subtree re-renders too — even if the child’s props didn’t change at all. React does this because re-rendering (calling the function and diffing the resulting elements) is usually cheap. The diffing algorithm is smart about skipping DOM writes for unchanged parts, but the JavaScript function calls themselves still happen. For small components this is invisible. For components doing expensive work — sorting a large array, rendering thousands of list items, running a heavy calculation — those wasted re-renders add up and become visible jank.

React’s optimization tools all work on the same principle: skip work by remembering the previous result and reusing it if the inputs haven’t changed. React.memo remembers a component’s last rendered output and skips re-rendering if props are shallowly equal. useMemo remembers the result of a calculation and skips recalculating if its dependencies haven’t changed. useCallback remembers a function reference so it doesn’t get recreated (and break memoization) on every render. lazy and Suspense defer loading code you don’t need yet, shrinking the initial bundle. None of these fix bugs — they are purely about avoiding redundant work, so always measure with the React DevTools Profiler before reaching for them.

Syntax

const MemoizedComponent = memo(Component, arePropsEqual?);

const cachedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

const cachedFn = useCallback(() => { doSomething(a, b); }, [a, b]);

const LazyComponent = lazy(() => import("./LazyComponent.jsx"));
<Suspense fallback={<Loading />}>
  <LazyComponent />
</Suspense>
API What it caches When to use it
memo The rendered output of a whole component Component re-renders often with the same props and does non-trivial work
useMemo The return value of a function An expensive calculation (sorting, filtering, aggregating large data)
useCallback A function reference Passing a callback to a memoized child, or as a dependency of another hook
lazy / Suspense The loading of a component’s code Large components not needed on initial render (routes, modals, charts)

Examples

Example 1: An unnecessary re-render

import { useState } from "react";

function ExpensiveList({ items }) {
  console.log("ExpensiveList rendered");
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

function App() {
  const [count, setCount] = useState(0);
  const [items] = useState([
    { id: 1, name: "Apple" },
    { id: 2, name: "Banana" },
    { id: 3, name: "Cherry" },
  ]);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ExpensiveList items={items} />
    </div>
  );
}

export default App;

Output:

ExpensiveList rendered
ExpensiveList rendered
ExpensiveList rendered
...(logged again on every click)

This renders a button reading “Count: 0” and a bulleted list of Apple, Banana, Cherry. The items array never changes, but every click on the button updates count, causing App to re-render, which causes ExpensiveList to re-render too — even though its props are identical. For a three-item list this is invisible; for a list with expensive rendering logic, it’s wasted work on every keystroke or click.

Example 2: Fixing it with React.memo

import { memo } from "react";

const ExpensiveList = memo(function ExpensiveList({ items }) {
  console.log("ExpensiveList rendered");
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
});

export default ExpensiveList;

Swapping in this memo-wrapped version of ExpensiveList (used in place of the one above) renders the exact same list, but clicking the counter button no longer logs “ExpensiveList rendered” after the first render. React compares the new items prop to the previous one; since it’s the same array reference, memo skips re-rendering the component entirely and reuses the last output.

Example 3: useMemo for an expensive calculation

import { useState, useMemo } from "react";

function slowSum(n) {
  let total = 0;
  for (let i = 0; i < n; i++) {
    total += i;
  }
  return total;
}

function SumCalculator() {
  const [n, setN] = useState(1000000);
  const [theme, setTheme] = useState("light");

  const sum = useMemo(() => slowSum(n), [n]);

  return (
    <div className={theme}>
      <p>Sum from 0 to {n}: {sum}</p>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        Toggle theme
      </button>
    </div>
  );
}

export default SumCalculator;

This renders “Sum from 0 to 1000000: 499999500000” and a “Toggle theme” button. Clicking the button toggles the wrapping div‘s className between “light” and “dark” instantly. Without useMemo, slowSum(n) would re-run on every render, including the theme toggle, even though n never changed. Because the dependency array is [n], React only recalculates the sum when n itself changes.

Example 4: useCallback with a memoized child

import { useState, useCallback, memo } from "react";

const AddButton = memo(function AddButton({ onAdd }) {
  console.log("AddButton rendered");
  return <button onClick={onAdd}>Add item</button>;
});

function TodoApp() {
  const [items, setItems] = useState([]);
  const  = useState("");

  const handleAdd = useCallback(() => {
    setItems((prev) => [...prev, { id: prev.length + 1, text }]);
  }, );

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <AddButton onAdd={handleAdd} />
      <ul>
        {items.map((item) => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
    </div>
  );
}

export default TodoApp;

This renders a text input, an “Add item” button, and a growing list of added entries. Typing in the input updates text and re-renders TodoApp, but it does not log “AddButton rendered” on every keystroke, because useCallback only produces a new handleAdd function reference when text changes as a dependency — and since text is read inside the callback via the state setter’s updater form isn’t even needed here for items, the function identity stays stable across renders that don’t change text. Without useCallback, a brand-new function would be created on every render, and AddButton — even though it’s wrapped in memo — would re-render every time because its onAdd prop would never be reference-equal to the previous one.

Example 5: Code-splitting with lazy and Suspense

import { lazy, Suspense } from "react";

const HeavyChart = lazy(() => import("./HeavyChart.jsx"));

function Dashboard() {
  return (
    <div>
      <h2>Dashboard</h2>
      <Suspense fallback={<p>Loading chart...</p>}>
        <HeavyChart />
      </Suspense>
    </div>
  );
}

export default Dashboard;

This renders the “Dashboard” heading immediately. While the separate JavaScript bundle for HeavyChart is being downloaded, “Loading chart…” is shown in its place; once the module finishes loading, Suspense swaps the fallback out for the real chart. HeavyChart‘s code is split into its own file and is only fetched when Dashboard actually renders, shrinking the JavaScript the browser has to download and parse up front.

How it works step by step / Under the hood

  • On mount: React calls the component function, builds the element tree, and commits every node to the real DOM. Nothing to skip yet — everything is new.
  • On a state update: React re-renders the component that owns the changed state, then — by default — every descendant in that subtree. For each descendant wrapped in memo, React first does a shallow comparison of the new props object against the previous one; if every prop is === equal, React reuses the previous render output and skips calling that component’s function at all. For a useMemo or useCallback call inside a component that does re-render, React compares the new dependency array to the old one entry-by-entry with Object.is; if every entry is unchanged, it returns the cached value or function instead of running the callback again.
  • Why hooks must run in the same order: React tracks hook state as a plain linked list per component instance, matched up purely by call order, not by name. If a hook call is skipped or added conditionally between renders, every hook after it reads the wrong slot of stored state — which is why hooks can never live inside if statements, loops, or nested functions.
  • On unmount: React runs cleanup functions (from useEffect) in reverse order and discards the component’s Fiber node, along with any memoized values it held — there is nothing to reuse across a full unmount/remount.

Common Mistakes

Mistake 1: Mutating state instead of replacing it

function AddItemBroken({ items, setItems }) {
  function handleAdd(newItem) {
    items.push(newItem); // mutates the existing array in place
    setItems(items); // same reference as before
  }

  return <button onClick={() => handleAdd({ id: 4, name: "Date" })}>Add</button>;
}

This looks like it should work, but items.push mutates the array in place, so the array passed to setItems is the exact same reference React already has. React (and any memo-wrapped consumer of this list) sees no change and may skip re-rendering entirely — the new item can silently fail to appear. Always create a new array or object instead:

function AddItemFixed({ items, setItems }) {
  function handleAdd(newItem) {
    setItems([...items, newItem]);
  }

  return <button onClick={() => handleAdd({ id: 4, name: "Date" })}>Add</button>;
}

Mistake 2: A stale closure from a missing dependency

import { useState, useEffect } from "react";

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // always reads the count from the first render
    }, 1000);
    return () => clearInterval(id);
  }, []); // missing 'count' dependency

  return <p>Count: {count}</p>;
}

Because the effect’s dependency array is [], the effect only runs once, and the interval callback captures count as it was on that first render (0) forever — the displayed count gets stuck at 1 instead of incrementing. Either add count to the dependency array (which recreates the interval every tick) or, better, use the functional updater form so the callback never needs count at all:

import { useState, useEffect } from "react";

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount((prev) => prev + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <p>Count: {count}</p>;
}

Mistake 3: Calling a hook conditionally

function Profile({ isLoggedIn }) {
  if (isLoggedIn) {
    const [name, setName] = useState(""); // hook called inside a condition
  }
  return <div>Profile</div>;
}

Calling useState only when isLoggedIn is true means the number of hooks called changes between renders, which desyncs React’s internal hook list and throws a “Rendered fewer hooks than expected” error (or worse, silently corrupts other hooks’ state). Hooks must always be called unconditionally at the top level; put the condition inside the hook or around the JSX instead:

function Profile({ isLoggedIn }) {
  const [name, setName] = useState("");
  if (!isLoggedIn) {
    return <div>Please log in</div>;
  }
  return <div>Profile: {name}</div>;
}

Mistake 4: Memoizing everything by default

const label = useMemo(() => "Total: " + count, [count]);

This isn’t broken, but it’s pointless — string concatenation is far cheaper than the bookkeeping useMemo itself does (storing the dependency array, comparing it, storing the cached value). Wrapping every value and every component in useMemo, useCallback, and memo “just in case” adds memory overhead and comparison cost on every render without a matching benefit. Reach for these tools only after profiling shows an actual expensive render or calculation.

Best Practices

  • Profile first with the React DevTools Profiler — don’t guess which component is slow.
  • Reserve useMemo for genuinely expensive calculations (sorting/filtering large arrays, heavy math), not cheap string or arithmetic operations.
  • Reserve React.memo for components that re-render often with unchanged props and do meaningful work when they render.
  • When passing props to a memo-wrapped child, avoid creating new object, array, or function literals inline in JSX — they break reference equality every render and defeat the memoization.
  • Pair useCallback and useMemo with an honest, complete dependency array; use the functional updater form (setCount(prev => prev + 1)) to avoid needing state values as dependencies at all.
  • Keep state as local as possible — lifting state higher than necessary forces more components to re-render on every update.
  • Split large route components or rarely-used UI (modals, charts, admin panels) with lazy and Suspense to shrink the initial bundle.
  • Always give list items a stable, unique key — using array indexes as keys can cause React to reuse the wrong DOM node and re-render more than necessary when items are reordered.
  • Always test performance in a production build; the development build includes extra checks and warnings that make everything look slower than it really is.

Practice Exercises

  • Build a Parent component with a counter and a child Greeting component that just renders a name prop and logs to the console on every render. Confirm in the console that Greeting re-renders on every counter click, then wrap it in memo and confirm the log stops appearing.
  • Write a component that filters a hardcoded array of 10,000 numbers to find all even numbers, and displays the count. Wrap the filtering logic in useMemo keyed on the array, then add an unrelated piece of state (like a toggle button) and verify in the console that the filter only re-runs when the array changes, not when the toggle changes.
  • Take the stale-closure Timer example from the Common Mistakes section, intentionally reintroduce the bug (remove the functional updater), observe the counter get stuck, and then fix it by switching back to setCount(prev => prev + 1).

Summary

  • Rendering means calling your component function; by default, a re-render of a parent re-renders every child in its subtree, whether or not that child’s props changed.
  • React.memo skips re-rendering a component when its props are shallowly equal to the previous render.
  • useMemo caches the return value of an expensive calculation between renders, recomputing only when its dependencies change.
  • useCallback caches a function reference so it stays stable across renders, which matters when that function is passed to a memo-wrapped child or used as another hook’s dependency.
  • lazy and Suspense split code into separate bundles that load only when needed, shrinking initial load time.
  • Never mutate state or props directly — always create new arrays and objects so React can correctly detect changes.
  • Hooks must always run in the same order on every render — never call them conditionally or inside loops.
  • Optimize based on profiling data, not guesses — unnecessary memoization adds overhead without benefit.