DevLift
Back to Blog

React `memo`, `useMemo`, and `useCallback`: The Performance Trio You're Probably Misusing

Most useMemo and useCallback calls in the wild accomplish nothing. Learn exactly when each hook works, why one unstable prop breaks the whole memoization chain, and when to just delete it.

Admin
August 2, 20268 min read0 views

React memo, useMemo, and useCallback: The Performance Trio You're Probably Misusing

Your profiler shows a slow component. You reach for useMemo. Still slow. You add useCallback. Nothing. You wrap the child in React.memo. Still sluggish.

Sound familiar? These three APIs are the most cargo-culted performance tools in React. Most codebases are littered with useCallback calls that accomplish nothing, useMemo wrappers around trivial computations, and React.memo that fires on every render anyway because one prop sneaks in as a new object reference.

Let's sort out what each one actually does, when the combination matters, and when you should just delete the whole thing.

What React Actually Does on Re-render

Before any memoization makes sense, understand the default behavior.

When a parent re-renders, every child component defined inside it re-renders too — regardless of whether props changed. React calls each child's render function again. That's the baseline.

function CourseCatalog() {
  const [search, setSearch] = useState('');
 
  return (
    <div>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      <CourseList />  {/* re-renders on every keystroke */}
      <Sidebar />     {/* re-renders on every keystroke */}
    </div>
  );
}

For most components this is fine. Rendering is fast. The real cost shows up when child renders trigger expensive computations, deep reconciliation trees, or layout work. Measure before you optimize — React DevTools Profiler is your starting point, not useMemo.

React.memo — Stop Re-rendering When Props Haven't Changed

React.memo wraps a component and bails out of re-rendering if props haven't changed (shallow comparison).

// Without memo — re-renders whenever CourseCatalog renders
function PlainCourseCard({ title, instructor, enrolled }: CourseCardProps) {
  return (
    <div className="rounded-lg border p-4">
      <h3 className="font-semibold">{title}</h3>
      <p className="text-sm text-muted-foreground">{instructor}</p>
      {enrolled && <Badge variant="secondary">Enrolled</Badge>}
    </div>
  );
}
 
// With memo — only re-renders when title, instructor, or enrolled change
const CourseCard = memo(function CourseCard({ title, instructor, enrolled }: CourseCardProps) {
  return (
    <div className="rounded-lg border p-4">
      <h3 className="font-semibold">{title}</h3>
      <p className="text-sm text-muted-foreground">{instructor}</p>
      {enrolled && <Badge variant="secondary">Enrolled</Badge>}
    </div>
  );
});

Good candidates: list items rendering the same data repeatedly, sidebars, headers — anything that participates in a lot of renders but gets the same props most of the time.

Bad candidates: components that almost always receive different props when their parent renders. memo adds overhead (storing previous props, running the comparison) for zero gain.

The Referential Equality Problem

Here's where things break. React.memo uses shallow equality. For primitives (strings, numbers, booleans), this works perfectly. For objects, arrays, and functions — it fails silently.

function CourseCatalog() {
  const [count, setCount] = useState(0);
 
  // NEW object reference on every render — memo on child is useless
  const filters = { status: 'active', page: 1 };
 
  // NEW function reference on every render — memo on child is useless  
  const handleEnroll = (courseId: string) => enrollUser(courseId);
 
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <MemoizedCourseList filters={filters} onEnroll={handleEnroll} />
    </>
  );
}

filters is a new object every render. Even though { status: 'active', page: 1 } looks identical, it's {} !== {} from JavaScript's perspective. memo's shallow check sees a changed prop and re-renders anyway. You wrapped the component for nothing.

useCallback — Stabilize Function References

useCallback returns the same function reference between renders, as long as dependencies don't change.

function CourseCatalog() {
  const [count, setCount] = useState(0);
  const [enrolledIds, setEnrolledIds] = useState<Set<string>>(new Set());
 
  // Stable reference — same function object across renders
  const handleEnroll = useCallback((courseId: string) => {
    setEnrolledIds(prev => new Set([...prev, courseId]));
  }, []); // empty deps: only created once, never recreated
 
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <MemoizedCourseList onEnroll={handleEnroll} />
    </>
  );
}
 
const MemoizedCourseList = memo(function CourseList({
  onEnroll,
}: {
  onEnroll: (id: string) => void;
}) {
  // Only re-renders when onEnroll reference changes
  return <ul>{/* ... */}</ul>;
});

Now when count changes and CourseCatalog re-renders, MemoizedCourseList receives the same onEnroll reference — memo's shallow comparison passes and the child skips re-rendering.

The critical insight: useCallback alone does nothing for re-render performance. It only matters when paired with a memoized component that depends on reference equality to skip renders. If CourseList isn't wrapped in memo, stabilizing handleEnroll accomplishes nothing. Nothing at all.

useMemo — Cache Expensive Computations

useMemo caches the return value of a computation and only recomputes when dependencies change.

Two legitimate use cases:

1. Genuinely expensive calculations

function CourseSearch({ courses, query, filters }: SearchProps) {
  // Without useMemo: reruns every render, even on unrelated state changes
  const filteredCourses = useMemo(() => {
    return courses
      .filter(c => {
        const matchesQuery = c.title.toLowerCase().includes(query.toLowerCase());
        const matchesDifficulty =
          filters.difficulty === 'all' || c.difficulty === filters.difficulty;
        const meetsRating = c.rating >= filters.minRating;
        return matchesQuery && matchesDifficulty && meetsRating;
      })
      .sort((a, b) => b.enrollmentCount - a.enrollmentCount);
  }, [courses, query, filters.difficulty, filters.minRating]);
 
  return <VirtualizedCourseGrid courses={filteredCourses} />;
}

The "expensive" threshold is whatever shows up in your Profiler flamegraph. Filtering 10 items won't. Filtering 10,000 items with multiple predicates and a sort might. Don't guess — wrap it in console.time() or read the Profiler, then decide.

2. Stabilizing object/array references for memoized children

function Dashboard({ userId, startDate, endDate }: DashboardProps) {
  // Without useMemo: new object every render → breaks memo on AnalyticsPanel
  const queryParams = useMemo(
    () => ({ userId, from: startDate, to: endDate }),
    [userId, startDate, endDate]
  );
 
  return <AnalyticsPanel params={queryParams} />;
}
 
const AnalyticsPanel = memo(function AnalyticsPanel({
  params,
}: {
  params: QueryParams;
}) {
  // Expensive: fetches and renders charts
  const data = useAnalyticsData(params);
  return <ChartGrid data={data} />;
});

Here useMemo isn't about a slow computation — creating a plain object is trivial. It's about giving AnalyticsPanel a stable prop reference so memo can do its job.

The Memoization Chain

This is the footgun most people step on. Memoization is all-or-nothing at the component boundary.

One non-stable prop breaks the entire chain.

function Parent() {
  const [count, setCount] = useState(0);
 
  const stableCallback = useCallback(() => doSomething(), []);
  const stableData = useMemo(() => ({ key: 'value' }), []);
 
  // Inline style object — new reference every render
  const style = { margin: '16px' }; // 💥 memo will still fire
 
  return (
    <MemoizedChild
      callback={stableCallback}  // ✓ stable
      data={stableData}          // ✓ stable
      style={style}              // ✗ new reference → memo is bypassed
    />
  );
}

MemoizedChild re-renders on every Parent render because style is always a new object. The useCallback and useMemo work you did is completely wasted.

Every claim in this section is render-counted against React 19.2.3, mounting each variant and driving two parent updates:

SetupChild renders after 2 parent updates…under StrictMode
Plain child, no memo36
memo, no props12
memo + inline object prop36
memo + useMemo-stabilised object prop12
Plain child + useCallback prop (no memo)36
memo + two stable props + one inline style36

The fifth row is the one worth internalising: useCallback on its own bought exactly nothing.

⚠️

Read the second column before you go counting renders in npm run dev. The Next.js App Router runs development in React StrictMode by default, and StrictMode deliberately invokes your component function twice per render to smoke out impure renders. Every number in the first column doubles — 3/1/3/1/3/3 becomes 6/2/6/2/6/6. Nothing is broken and the ratios are unchanged, so the conclusions all hold; but if you add a console.log to a component and see 6 where this table says 3, you are looking at StrictMode, not at a memoization failure. The doubling is development-only — StrictMode's double invocation is stripped from production builds — and React DevTools Profiler reports the two passes as a single commit, which is one reason the Profiler is the better instrument here than a counter.

Rendering diagram...

Every prop passed to a memoized component needs to be either a primitive, a memoized value, or a stable callback. One leak and the chain collapses.

When NOT to Use These Hooks

Don't memoize simple components. If a component renders a label or a static layout, the render is already trivial and memo's prop comparison is pure overhead. Measure it in the Profiler before you assume otherwise.

// Pointless — a label like this is not what makes your page slow
const StatusLabel = memo(function StatusLabel({ text }: { text: string }) {
  return <span className="text-xs text-gray-400">{text}</span>;
});

Don't useMemo cheap operations.

// This is silly — string concatenation is not a performance problem
const memoisedName = useMemo(
  () => `${user.firstName} ${user.lastName}`,
  [user.firstName, user.lastName]
);
 
// Just write this
const displayName = `${user.firstName} ${user.lastName}`;

Don't useCallback on DOM element handlers. <button>, <input>, and other built-in HTML elements don't do referential equality checks on props. They're not memoized React components.

// Accomplishes nothing
const handleClick = useCallback(() => setIsOpen(true), []);
<button onClick={handleClick}>Open</button>
 
// Just write this
<button onClick={() => setIsOpen(true)}>Open</button>

Don't memoize when dependencies change as often as the render itself.

function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');
 
  // query changes on every keystroke — cache invalidates constantly
  // useCallback here does nothing useful
  const handleSearch = useCallback(() => {
    onSearch(query);
  }, [query, onSearch]);
}

If your dependencies update every render, the memoization never sticks.

⚠️

A large share of the useMemo/useCallback calls in any mature codebase are doing nothing — go read your own git log if you want the number for your project. They don't break anything, but they add cognitive overhead, make stale closure bugs harder to spot, and create a false sense of optimization.

The Pattern That Actually Works

Everything above says memoization only pays off at a boundary where the props genuinely don't change. So the first job in a real screen is to find where that boundary actually is — and in a search-as-you-type list it is not the list component. It's a row.

Here's the wiring, then the render counts.

// Step 1: memoize the component whose props are actually stable — the row.
// `course` comes from the courses array (same object identity across renders)
// and `onEnroll` is stabilised below, so memo can genuinely bail out here.
const CourseItem = memo(function CourseItem({
  course,
  onEnroll,
}: {
  course: Course;
  onEnroll: (id: string) => void;
}) {
  return (
    <li>
      {course.title}
      <button onClick={() => onEnroll(course.id)}>Enrol</button>
    </li>
  );
});
 
// Not memoized. `search` changes on every keystroke, so this has to re-render
// on every keystroke — that is the correct behaviour, not a leak to plug.
function CourseList({
  courses,
  search,
  onEnroll,
}: {
  courses: Course[];
  search: string;
  onEnroll: (id: string) => void;
}) {
  const filtered = useMemo(
    () => courses.filter(c => c.title.toLowerCase().includes(search.toLowerCase())),
    [courses, search]
  );
 
  return (
    <ul>
      {filtered.map(course => (
        <CourseItem key={course.id} course={course} onEnroll={onEnroll} />
      ))}
    </ul>
  );
}
 
function CourseCatalog() {
  const [search, setSearch] = useState('');
  const { courses } = useCourses();
 
  // Step 2: stable callback — this is the thing that lets CourseItem's memo work.
  const handleEnroll = useCallback((courseId: string) => {
    enrollInCourse(courseId);
  }, []);
 
  return (
    <div>
      <input
        value={search}
        onChange={e => setSearch(e.target.value)}
        placeholder="Search courses..."
      />
      <CourseList courses={courses} search={search} onEnroll={handleEnroll} />
    </div>
  );
}

Render-counted on five courses, mount plus five keystrokes (r, re, rea, reac, react, narrowing the list from 5 rows to 2), React 19.2.3, no StrictMode:

SetupCourseList rendersCourseItem renders
No memo on the row618
memo(CourseItem) + useCallback(handleEnroll)65
memo(CourseItem) but handleEnroll written inline618
…and adding memo(CourseList) on top of row 265

Three things fall out of that table, and one of them is a correction to how this pattern usually gets presented.

The win is real and it is at the row. 18 row renders down to 5 — and 5 is just the mount. After that, zero rows re-render on any keystroke, because every surviving row gets the identical course object and the identical onEnroll function, so memo's shallow compare passes. All the work of narrowing the list happens in CourseList's own render.

useCallback is load-bearing here, and only here. Row 3 is the control: keep memo(CourseItem) but write handleEnroll as an inline arrow and you are straight back to 18. This is the pairing from the useCallback section, finally with a number on it. useCallback without a memoized consumer does nothing; memo without stable props does nothing; together they do something.

Wrapping CourseList in memo does nothing at all — 6 renders either way. That's the last row, and it's worth dwelling on because putting memo there is the natural instinct and it is exactly the anti-pattern from twenty lines up: CourseList needs search, search changes as fast as the render itself, so its props change on every render and the comparison can never pass. It's not harmful, it just costs a prop comparison to learn nothing. If you want CourseList itself to bail out, you don't add memo — you change the props so a memoized component never receives the per-keystroke value. Doing that (moving the search filter out and handing CourseList only a stable filters object) takes it from 6 renders to 1, but then it isn't a search-as-you-type list any more. Which boundary you pick is a design decision; memo cannot make it for you.

The Mental Model Checklist

Before adding React.memo:

  • Does this component render frequently with identical props?
  • Is rendering it noticeably slow?
  • Are all its props primitives or values you control?

Before adding useCallback:

  • Is this function passed to a memo-wrapped component?
  • Or is it listed as a useEffect dependency that shouldn't re-run every render?

Before adding useMemo:

  • Is this computation genuinely slow (large arrays, complex derivations) — and did you measure it?
  • Or is this stabilizing an object/array prop for a memo-wrapped child?

If you can't answer yes to one of those, skip the hook.

React Compiler Changes the Equation

React Compiler (v1.0, released October 2025) applies memoization automatically at build time. It analyzes your components statically and inserts cache boundaries more granularly than most developers write by hand. It's a separate opt-in build plugin, not something React 19 turns on for you — you install it and add it to your Babel/bundler config.

With the compiler enabled, you generally don't need useMemo or useCallback for most cases. React.memo becomes largely unnecessary too. The compiler's memoization is per-reactive-scope, not per-hook-call, so it's more precise than what you'd write manually.

For new projects on React 19+, the compiler is worth evaluating before you start threading useCallback through every prop chain.

npm install babel-plugin-react-compiler

For existing codebases: the compiler works alongside existing memoization. No rush to strip it all out — but stop adding new manual memoization you don't need.

Use React DevTools Profiler's "Highlight updates when components render" setting to see what's actually re-rendering. Most of the time the re-renders are fast and acceptable. Optimize the ones that show up red repeatedly during normal interaction.

The bottom line: memoization is a tool for specific problems, not a default coding style. Profile, identify the actual bottleneck, and apply the minimum amount of memoization to fix it. Then stop.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read