React.memo

React.memo is a higher-order component that wraps a function component and tells React to skip re-rendering it if its props haven’t changed since the last render. It exists purely for performance: your UI will produce the same result with or without it, but for components that render often or do expensive work, it can prevent a lot of wasted rendering. It’s not a general-purpose optimization switch, though — used carelessly it can add overhead instead of removing it, which is why understanding how its comparison actually works matters.

Overview / How it works

By default, when a parent component re-renders, React re-renders every child component in that subtree, even if a particular child’s props are exactly the same as before. This is a deliberate simplicity trade-off: React re-runs your function component to get a new tree of React elements (the Virtual DOM), diffs it against the previous tree (reconciliation), and only touches the real DOM nodes that actually changed (commit). The re-render of the function itself is usually cheap, so this default is fine almost everywhere.

Sometimes, though, a component is expensive to re-render — it does heavy calculations during render, renders a large list, or contains many nested children — and it keeps getting asked to re-render even though the data it depends on hasn’t changed. React.memo fixes this by memoizing the rendered output based on props. When the parent re-renders, React first performs a shallow comparison between the previous props object and the new props object for a memoized component. If every prop is Object.is-equal to its previous value, React reuses the last rendered result and skips calling your component function entirely. If any prop differs, it renders normally.

“Shallow comparison” is the key phrase to understand. React does not deep-compare objects or arrays — it compares each prop’s reference (for objects, arrays, and functions) or primitive value (for strings, numbers, booleans). This means a brand-new object or function passed as a prop, even one that looks identical in content to the last one, will be treated as “changed” because it has a different reference. That single fact explains almost every surprise people run into with memo, and it’s why memo is so often paired with useCallback and useMemo, which exist specifically to keep references stable across renders.

React.memo only affects whether a component re-renders because its parent re-rendered with the same props. It does not stop a component from re-rendering when its own state changes via useState, or when a value from useContext it subscribes to changes — those always cause a re-render regardless of memoization.

Syntax

import { memo } from "react";

const MemoizedComponent = memo(Component, arePropsEqual);
Part Description
memo Named export from react, imported as import { memo } from "react";.
Component The function component to memoize. Can be defined inline or referenced by name.
arePropsEqual Optional custom comparison function (prevProps, nextProps) => boolean. Return true to skip re-rendering (props are considered equal), false to re-render. If omitted, React uses a shallow comparison of all props.
MemoizedComponent The wrapped component you export and render in JSX exactly like the original — usage doesn’t change, only the re-render behavior does.

Examples

Example 1: A basic re-render problem, then the fix

Without memoization, this list re-renders every time the unrelated counter changes, even though the items array itself never changes:

import { useState } from "react";

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

export default function App() {
  const [count, setCount] = useState(0);
  const [items] = useState([
    { id: 1, name: "Apples" },
    { id: 2, name: "Bananas" },
  ]);

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

Output:

ExpensiveList rendered   (logs again after every click on the button)

Renders a button reading “Count: 0” and a two-item list. Each click increments the count and logs “ExpensiveList rendered” again, even though items is unchanged, because App re-rendering re-renders all of its children by default.

Wrapping ExpensiveList in memo fixes this:

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;

Output:

ExpensiveList rendered   (logs once on mount only; no further logs on click)

Because items comes from useState and is never reassigned, its reference stays identical across renders of App. With ExpensiveList memoized, React sees the same items reference on every re-render of App and skips re-rendering the list, so the log line appears only once, at mount.

Example 2: Pairing memo with useCallback

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

const Button = memo(function Button({ onClick, label }) {
  console.log(`Button "${label}" rendered`);
  return <button onClick={onClick}>{label}</button>;
});

export default function Toolbar() {
  const [count, setCount] = useState(0);

  const handleSave = useCallback(() => {
    console.log("Saved!");
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <Button onClick={handleSave} label="Save" />
    </div>
  );
}

Renders a counter and a “Save” button. Clicking “Increment” updates the count and re-renders Toolbar, but Button does not re-render, because handleSave is wrapped in useCallback with an empty dependency array, so it keeps the exact same function reference across renders. Combined with Button being memoized, React sees identical props (onClick and label unchanged) and skips it. If handleSave were defined as a plain arrow function inside Toolbar without useCallback, a new function would be created on every render, and memo would provide no benefit at all.

Example 3: Custom comparison function

import { memo } from "react";

function UserCard({ user }) {
  console.log(`Rendering ${user.name}`);
  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

function arePropsEqual(prevProps, nextProps) {
  return (
    prevProps.user.id === nextProps.user.id &&
    prevProps.user.name === nextProps.user.name &&
    prevProps.user.email === nextProps.user.email
  );
}

export default memo(UserCard, arePropsEqual);

Renders a card with a user’s name and email. If a parent component creates a brand-new user object on every render (for example, by mapping over data fetched from an API each time), the default shallow comparison would always see a new object reference and re-render every time. The custom arePropsEqual function instead compares the individual fields inside user, so UserCard only re-renders when id, name, or email actually change in value, regardless of whether the object wrapping them is a new reference.

How it works step by step / Under the hood

  • On first mount: React renders the memoized component normally — there’s no previous props to compare against, so the comparison step is skipped entirely.
  • On a parent re-render: before calling the memoized component’s function, React compares the new props object to the previous one, either with a shallow Object.is check on every key, or by calling your custom arePropsEqual function if one was provided.
  • If props are considered equal: React reuses the component’s last rendered output (its previous React element tree) and does not call the function again, does not run any of its hooks again, and does not touch the DOM for that subtree.
  • If props differ: React calls the function again, gets a new element tree, and reconciles it against the previous tree as usual, updating only the real DOM nodes that changed.
  • On the memoized component’s own state or context changes: memoization is irrelevant — the component always re-renders when its own useState/useReducer state updates or a subscribed context value changes, because that update doesn’t come from a parent prop comparison at all.
  • On unmount: behaves exactly like an unmemoized component — effect cleanup functions run, and the component is removed from the tree.

Common Mistakes

Mistake 1: Passing new object, array, or function literals as props

This defeats memoization completely, because a new reference is created on every parent render:

// Inside a parent component's render — runs on every render
<UserCard user={{ id: 1, name: "Ana", email: "ana@example.com" }} />

Even though the values are identical every time, { id: 1, ... } is a new object each render, so a shallow comparison always reports “changed” and memo never skips a render. Fix it by keeping the object reference stable — store it in state, hoist it out of the component, or wrap it with useMemo:

const user = useMemo(
  () => ({ id: 1, name: "Ana", email: "ana@example.com" }),
  []
);

// ...
<UserCard user={user} />

Mistake 2: Inverting the custom comparator’s return value

It’s easy to confuse arePropsEqual with the old class-component shouldComponentUpdate, which has the opposite meaning:

function arePropsEqual(prevProps, nextProps) {
  // WRONG: returns true when props DIFFER, which tells memo to SKIP
  // re-rendering exactly when it should re-render.
  return prevProps.value !== nextProps.value;
}

arePropsEqual must return true when props are equal (skip re-render) and false when they differ (re-render) — the reverse of shouldComponentUpdate‘s convention. The corrected version:

function arePropsEqual(prevProps, nextProps) {
  return prevProps.value === nextProps.value;
}

Best Practices

  • Reach for memo only after noticing an actual performance problem (e.g. with the React DevTools Profiler) — wrapping every component “just in case” adds a comparison cost to every render for components that were already cheap.
  • Always pair memo with useCallback for function props and useMemo for object/array props that the memoized component receives; otherwise the parent recreates them every render and memoization never kicks in.
  • Prefer memoizing components low in the tree that render often (list items, cards, rows) rather than a single top-level component that rarely re-renders anyway.
  • Use a custom arePropsEqual only when you specifically need to compare fields inside an object prop that changes reference but not value — don’t reach for it by default.
  • Remember that memoizing a component does nothing to prevent re-renders caused by its own state or context changes; combine with useMemo/useCallback upstream for a full solution.
  • Keep children in mind: passing JSX as children creates a new element on every parent render too, which can also defeat a shallow comparison.

Practice Exercises

  • Build a Timer component that displays a tick count updating every second via setInterval inside useEffect, alongside a sibling StaticGreeting component that just renders a name prop. Add a console.log to StaticGreeting, confirm it logs every second, then wrap it in memo and confirm the log stops appearing after the first render.
  • Create a memoized ProductRow component that receives an onAddToCart callback prop from its parent. First define the callback inline (a new arrow function each render) and observe that memo doesn’t prevent re-renders; then wrap the callback in useCallback and confirm the re-renders stop.
  • Write a custom arePropsEqual function for a PriceTag component that receives a price object shaped like { amount, currency }, so it only re-renders when amount or currency actually change, not when the parent creates a new price object with the same values.

Summary

  • React.memo wraps a function component so React can skip re-rendering it when its props haven’t changed, based on a shallow comparison by default.
  • It compares props reference-by-reference for objects, arrays, and functions, and value-by-value for primitives — it never deep-compares.
  • It has no effect on re-renders triggered by the memoized component’s own state or context changes.
  • New object, array, or function literals passed as props recreate a new reference every render and silently defeat memoization — stabilize them with useMemo and useCallback.
  • An optional second argument, a custom comparator, lets you control exactly how props are compared, but must return true for “equal, skip render” and false for “different, re-render.”
  • Use memo deliberately, on components that are expensive or re-render often, rather than everywhere by default.