← back to section

A slow interface is frustrating — the user clicks a button and the screen freezes. The typical reaction is to wrap every component in useMemo and React.memo "just in case," which makes the code harder to read while the interface stays just as slow. Let's look at how rendering actually works and what genuinely affects speed.

How React draws the screen

When something changes — state or props — React re-invokes the component function and builds a new virtual tree. It then diffs it against the old one and updates only the real DOM nodes that changed.

That is why a component re-render does not equal slow. The JavaScript function call itself takes microseconds. What is expensive is mutating the real DOM, and React tries to do that as little as possible.

A real problem shows up as a visible delay: the user clicked and the interface froze for half a second. Not "the component renders 12 times," but an actually perceptible stutter.

Finding the real bottleneck

Before optimizing anything, make sure the problem exists and find where it is. Two tools help here.

React DevTools Profiler — start the app, open the Profiler tab, hit Record, reproduce the slow action, stop recording. The profiler shows which component took how long and why it re-rendered.

Lighthouse in the browser — shows load metrics: First Contentful Paint, Time to Interactive. Points out what is slowing down the initial load.

The rule is simple: measure first, optimize second. Otherwise you spend time on things that are not actually hurting.

memo, useMemo, useCallback — three targeted tools

These three hooks are not for everywhere; they belong where the profiler has shown a problem.

React.memo

Wraps a component so it skips re-rendering when its props have not changed by reference. Useful when a parent component re-renders frequently while a child is expensive and receives the same data.

const ProductCard = React.memo(({ product }: { product: Product }) => {
  return <div>{product.name}</div>;
});

Without React.memo, every parent render re-renders ProductCard, even if product has not changed.

useMemo

Caches a computed value between renders. Useful when the computation is genuinely heavy — for example, sorting a large array.

const sorted = useMemo(
  () => products.slice().sort((a, b) => a.price - b.price),
  [products],
);

Recomputes only when products changes. On every render the dependency comparison itself takes time — so for cheap computations useMemo costs more than just recalculating.

useCallback

Stabilizes a function reference between renders. Needed when a function is passed to a React.memo component or into the dependency array of another hook.

const handleClick = useCallback(() => {
  dispatch({ type: "ADD", id });
}, [id, dispatch]);

Without useCallback, every render creates a new function with a new reference — React.memo treats this as a changed prop and re-renders the child.

When these tools cause harm

On simple components memoization does more harm than good:

  • useMemo on a cheap computation is slower than just computing it;
  • useCallback without a React.memo consumer is just dead code;
  • memoized code is harder to read and understand.

Heuristic: don't add memoization until the profiler shows it's slow without it.

Code-splitting — don't load unnecessary code upfront

The second cause of a slow start is the browser downloading the entire application bundle, even though the user only needs the landing screen.

React.lazy paired with Suspense solves this: a heavy section loads only when the user navigates to it.

const Dashboard = React.lazy(() => import("./Dashboard"));
const ReportEditor = React.lazy(() => import("./ReportEditor"));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/reports/edit" element={<ReportEditor />} />
      </Routes>
    </Suspense>
  );
}

The first screen loads only its own code. Heavy editors or charts are fetched on demand. This directly improves the initial load — the metric users actually feel.

Bundle size

Even with code-splitting, each chunk can be bloated. The most common cause is a library imported in full for the sake of one function.

To understand what takes up space, use bundle analyzers: webpack-bundle-analyzer or vite-bundle-visualizer. They produce an interactive map where it immediately becomes clear what is taking up room.

Common findings:

  • moment.js or date-fns imported wholesale instead of using named imports for the needed functions;
  • an icon library where only 3 icons are used;
  • duplicate dependencies at different versions.

After measuring — targeted action: named imports instead of default ones, replacing a heavy library with a lighter alternative, removing unused dependencies.

In short

  • Re-rendering a React component is cheap in itself — mutating the real DOM is expensive, and React minimizes that.
  • A real problem is visible: a noticeable lag while typing or scrolling.
  • Tools for finding it: React DevTools Profiler — for re-renders, Lighthouse — for load performance.
  • React.memo — skips re-rendering when props haven't changed.
  • useMemo — caches the result of an expensive computation.
  • useCallback — stabilizes a function reference for memo consumers.
  • Memoization is added precisely where the profiler indicates it's needed, not preemptively.
  • React.lazy + Suspense — route-based code-splitting, faster initial load.
  • A bundle analyzer will show what's weighing things down; often a single heavy library is the culprit.
  • State management — how state works in React and when you need an external store.
  • Error handling and Suspense — how Error Boundary works together with Suspense.
  • Testing: Vitest and Testing Library — how to test component behavior, not implementation details.