Skip to main content
React · lesson 11 of 22
React intermediate Lesson 11 of 22

Measuring Re-renders: memo, useMemo, and useCallback

Count real re-renders with and without memo, see why memo alone fails with an inline callback, and measure useMemo skipping an expensive call.

By SkillByExample · Updated

react memo useMemo useCallback performance

memo skips a re-render; useMemo skips an expensive computation. useCallback is subtler: the inline function literal passed to it still gets created every render, same as any other expression — useCallback just throws that new one away and hands back the identical function object it cached last time, which is what lets something downstream, like memo’s prop comparison, see a stable reference. Whether any of this actually does anything is a number to measure, not a feeling to trust, so every example below counts something real.

memo Skips a Re-render When Props Don’t Change

Without memo, a child re-renders every time its parent does, even with props that never change.

// src/measuring-rerenders/NoMemo.tsx
import { useState } from "react";

export let childRenders = 0;

function Child({ label }) {
  childRenders++;
  return <p>{label}</p>;
}

export function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Parent count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <Child label="static child" />
    </div>
  );
}
// src/measuring-rerenders/NoMemo.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Parent, childRenders } from "./NoMemo.js";

it("re-renders Child on every Parent click, even though its props never change", async () => {
  const user = userEvent.setup();
  render(<Parent />);
  const before = childRenders;

  await user.click(screen.getByRole("button", { name: "+1" }));
  await user.click(screen.getByRole("button", { name: "+1" }));
  await user.click(screen.getByRole("button", { name: "+1" }));

  console.log(`childRenders before: ${before}, after three clicks: ${childRenders}`);
  expect(childRenders).toBe(before + 3);
});
$ npx vitest run measuring-rerenders/NoMemo.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/NoMemo.test.tsx > re-renders Child on every Parent click, even though its props never change
childRenders before: 1, after three clicks: 4

 ✓ src/measuring-rerenders/NoMemo.test.tsx > re-renders Child on every Parent click, even though its props never change 171ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:42:27
   Duration  17.82s (environment 69%, setup 20%, import 9%, transform 1%, tests 1%)

The mount itself is one render, so childRenders starts the test at 1; three clicks add exactly three more, landing on 4. Wrap the same Child in memo and nothing else changes — label is still "static child" on every render.

// src/measuring-rerenders/WithMemo.tsx
import { memo, useState } from "react";

export let childRenders = 0;

const Child = memo(function Child({ label }) {
  childRenders++;
  return <p>{label}</p>;
});

export function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Parent count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <Child label="static child" />
    </div>
  );
}
// src/measuring-rerenders/WithMemo.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Parent, childRenders } from "./WithMemo.js";

it("skips re-rendering the memoized Child when its props stay the same", async () => {
  const user = userEvent.setup();
  render(<Parent />);
  const before = childRenders;

  await user.click(screen.getByRole("button", { name: "+1" }));
  await user.click(screen.getByRole("button", { name: "+1" }));
  await user.click(screen.getByRole("button", { name: "+1" }));

  console.log(`childRenders before: ${before}, after three clicks: ${childRenders}`);
  expect(childRenders).toBe(before);
});
$ npx vitest run measuring-rerenders/WithMemo.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/WithMemo.test.tsx > skips re-rendering the memoized Child when its props stay the same
childRenders before: 1, after three clicks: 1

 ✓ src/measuring-rerenders/WithMemo.test.tsx > skips re-rendering the memoized Child when its props stay the same 191ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:42:49
   Duration  24.88s (environment 70%, setup 21%, import 7%, transform 1%, tests 1%)

Both tests click the +1 button three times. The plain Child’s render counter went up by 3 (1 to 4, in the previous output); the memoized one stays exactly where it started, at 1 — label is the string "static child" both times, and memo compares it with Object.is, which is true for the same primitive string.

memo Alone Doesn’t Help with an Unstable Callback Prop

A new inline function is never equal to the last one, so memo still re-renders if a callback prop is recreated every render.

// src/measuring-rerenders/MemoWithCallback.tsx
import { memo, useState } from "react";

export let childRenders = 0;

const Button = memo(function Button({ onAdd }) {
  childRenders++;
  return <button onClick={onAdd}>Add</button>;
});

export function Parent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState(0);

  return (
    <div>
      <p>Unrelated count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1 unrelated</button>
      <p>Items: {items}</p>
      <Button onAdd={() => setItems((i) => i + 1)} />
    </div>
  );
}
// src/measuring-rerenders/MemoWithCallback.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Parent, childRenders } from "./MemoWithCallback.js";

it("still re-renders the memoized Button, because onAdd is a new function every render", async () => {
  const user = userEvent.setup();
  render(<Parent />);
  const before = childRenders;

  await user.click(screen.getByRole("button", { name: "+1 unrelated" }));
  await user.click(screen.getByRole("button", { name: "+1 unrelated" }));

  console.log(`childRenders before: ${before}, after two unrelated clicks: ${childRenders}`);
  expect(childRenders).toBe(before + 2);
});
$ npx vitest run measuring-rerenders/MemoWithCallback.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/MemoWithCallback.test.tsx > still re-renders the memoized Button, because onAdd is a new function every render
childRenders before: 1, after two unrelated clicks: 3

 ✓ src/measuring-rerenders/MemoWithCallback.test.tsx > still re-renders the memoized Button, because onAdd is a new function every render 199ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:43:18
   Duration  23.13s (environment 70%, setup 19%, import 9%, transform 1%, tests 1%)

Button is memoized, but onAdd={() => setItems((i) => i + 1)} creates a brand-new function on every Parent render — two unrelated clicks still move the counter from 1 to 3, re-rendering Button both times. Wrap the callback in useCallback with an empty dependency list, and the same function reference survives across renders.

// src/measuring-rerenders/MemoWithUseCallback.tsx
import { memo, useCallback, useState } from "react";

export let childRenders = 0;

const Button = memo(function Button({ onAdd }) {
  childRenders++;
  return <button onClick={onAdd}>Add</button>;
});

export function Parent() {
  const [count, setCount] = useState(0);
  const [items, setItems] = useState(0);
  const handleAdd = useCallback(() => setItems((i) => i + 1), []);

  return (
    <div>
      <p>Unrelated count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1 unrelated</button>
      <p>Items: {items}</p>
      <Button onAdd={handleAdd} />
    </div>
  );
}
// src/measuring-rerenders/MemoWithUseCallback.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Parent, childRenders } from "./MemoWithUseCallback.js";

it("keeps the memoized Button from re-rendering once onAdd is stable via useCallback", async () => {
  const user = userEvent.setup();
  render(<Parent />);
  const before = childRenders;

  await user.click(screen.getByRole("button", { name: "+1 unrelated" }));
  await user.click(screen.getByRole("button", { name: "+1 unrelated" }));

  console.log(`childRenders before: ${before}, after two unrelated clicks: ${childRenders}`);
  expect(childRenders).toBe(before);
});
$ npx vitest run measuring-rerenders/MemoWithUseCallback.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/MemoWithUseCallback.test.tsx > keeps the memoized Button from re-rendering once onAdd is stable via useCallback
childRenders before: 1, after two unrelated clicks: 1

 ✓ src/measuring-rerenders/MemoWithUseCallback.test.tsx > keeps the memoized Button from re-rendering once onAdd is stable via useCallback 157ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:43:46
   Duration  20.73s (environment 70%, setup 20%, import 8%, transform 1%, tests 1%)

Same two clicks, same memoized Button, but childRenders no longer moves off 1 — useCallback(() => ..., []) hands back the exact same function object on every render of Parent (see this lesson’s last Practice task for direct proof of that), so memo’s prop comparison finally finds onAdd unchanged.

useMemo Skips an Expensive Recomputation

Without useMemo, a derived value recomputes on every render, even one triggered by something unrelated.

// src/measuring-rerenders/NoUseMemo.tsx
import { useState } from "react";

export let sortCalls = 0;

function sortNumbers(nums) {
  sortCalls++;
  return [...nums].sort((a, b) => a - b);
}

export function Dashboard({ numbers }) {
  const [filter, setFilter] = useState("");
  const sorted = sortNumbers(numbers);

  return (
    <div>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter" />
      <p>Sorted: {sorted.join(",")}</p>
    </div>
  );
}
// src/measuring-rerenders/NoUseMemo.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dashboard, sortCalls } from "./NoUseMemo.js";

it("re-sorts on every keystroke, even though the numbers never change", async () => {
  const user = userEvent.setup();
  render(<Dashboard numbers={[3, 1, 2]} />);
  const before = sortCalls;

  await user.type(screen.getByPlaceholderText("filter"), "abc");

  console.log(`sortCalls before: ${before}, after three keystrokes: ${sortCalls}`);
  expect(sortCalls).toBe(before + 3);
});
$ npx vitest run measuring-rerenders/NoUseMemo.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/NoUseMemo.test.tsx > re-sorts on every keystroke, even though the numbers never change
sortCalls before: 1, after three keystrokes: 4

 ✓ src/measuring-rerenders/NoUseMemo.test.tsx > re-sorts on every keystroke, even though the numbers never change 149ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:44:11
   Duration  19.94s (environment 68%, setup 19%, import 11%, transform 1%, tests 1%)

Typing "abc" into an unrelated filter field re-sorts the same three numbers on every keystroke — sortCalls goes from 1 (the mount) to 4, one more call per character. Wrapping the call in useMemo with [numbers] as its dependency fixes that.

// src/measuring-rerenders/WithUseMemo.tsx
import { useMemo, useState } from "react";

export let sortCalls = 0;

function sortNumbers(nums) {
  sortCalls++;
  return [...nums].sort((a, b) => a - b);
}

export function Dashboard({ numbers }) {
  const [filter, setFilter] = useState("");
  const sorted = useMemo(() => sortNumbers(numbers), [numbers]);

  return (
    <div>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter" />
      <p>Sorted: {sorted.join(",")}</p>
    </div>
  );
}
// src/measuring-rerenders/WithUseMemo.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dashboard, sortCalls } from "./WithUseMemo.js";

it("sorts once and reuses the result while the numbers prop stays the same", async () => {
  const user = userEvent.setup();
  render(<Dashboard numbers={[3, 1, 2]} />);
  const before = sortCalls;

  await user.type(screen.getByPlaceholderText("filter"), "abc");

  console.log(`sortCalls before: ${before}, after three keystrokes: ${sortCalls}`);
  expect(sortCalls).toBe(before);
});
$ npx vitest run measuring-rerenders/WithUseMemo.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/WithUseMemo.test.tsx > sorts once and reuses the result while the numbers prop stays the same
sortCalls before: 1, after three keystrokes: 1

 ✓ src/measuring-rerenders/WithUseMemo.test.tsx > sorts once and reuses the result while the numbers prop stays the same 139ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:44:36
   Duration  21.06s (environment 66%, setup 22%, import 10%, transform 1%, tests 1%)

sortCalls stays at 1 after typing three characters — numbers is still the same array reference the whole time, so useMemo returns its cached sorted value instead of calling sortNumbers again. The component still re-renders on every keystroke, to show the updated filter text; only the sort itself was skipped.

Practice

1. Give memo a custom comparison function that ignores a timestamp prop which changes on every render.

Solution
// src/measuring-rerenders/practice/CustomCompare.tsx
import { memo, useState } from "react";

export let childRenders = 0;

const Child = memo(
  function Child({ label, timestamp }) {
    childRenders++;
    return <p>{label}</p>;
  },
  (prevProps, nextProps) => prevProps.label === nextProps.label
);

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <Child label="static child" timestamp={Date.now()} />
    </div>
  );
}
// src/measuring-rerenders/practice/CustomCompare.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Parent, childRenders } from "./CustomCompare.js";

it("a custom comparison function can ignore a prop that changes every render", async () => {
  const user = userEvent.setup();
  render(<Parent />);
  const before = childRenders;

  await user.click(screen.getByRole("button", { name: "+1" }));
  await user.click(screen.getByRole("button", { name: "+1" }));
  await user.click(screen.getByRole("button", { name: "+1" }));

  console.log(`childRenders before: ${before}, after three clicks: ${childRenders}`);
  expect(childRenders).toBe(before);
});
$ npx vitest run measuring-rerenders/practice/CustomCompare.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/practice/CustomCompare.test.tsx > a custom comparison function can ignore a prop that changes every render
childRenders before: 1, after three clicks: 1

 ✓ src/measuring-rerenders/practice/CustomCompare.test.tsx > a custom comparison function can ignore a prop that changes every render 234ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:45:02
   Duration  24.37s (environment 68%, setup 21%, import 8%, transform 1%, tests 1%)

memo’s second argument replaces the default Object.is-per-prop check entirely — here it only looks at label, so a fresh timestamp every render no longer forces a re-render; the counter stays at 1 through all three clicks.

2. Memoize a word count that only depends on a text prop, while an unrelated counter re-renders the component.

Solution
// src/measuring-rerenders/practice/WordCount.tsx
import { useMemo, useState } from "react";

export let countCalls = 0;

function countWords(text) {
  countCalls++;
  return text.trim().split(/\s+/).filter(Boolean).length;
}

export function WordCount({ text }) {
  const [unrelated, setUnrelated] = useState(0);
  const words = useMemo(() => countWords(text), [text]);

  return (
    <div>
      <p>Words: {words}</p>
      <p>Unrelated: {unrelated}</p>
      <button onClick={() => setUnrelated((u) => u + 1)}>+1 unrelated</button>
    </div>
  );
}
// src/measuring-rerenders/practice/WordCount.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WordCount, countCalls } from "./WordCount.js";

it("recomputes the word count only when the text itself changes", async () => {
  const user = userEvent.setup();
  render(<WordCount text="the quick brown fox" />);
  const before = countCalls;

  await user.click(screen.getByRole("button", { name: "+1 unrelated" }));
  await user.click(screen.getByRole("button", { name: "+1 unrelated" }));

  console.log(`countCalls before: ${before}, after two unrelated clicks: ${countCalls}`);
  expect(countCalls).toBe(before);
});
$ npx vitest run measuring-rerenders/practice/WordCount.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/practice/WordCount.test.tsx > recomputes the word count only when the text itself changes
countCalls before: 1, after two unrelated clicks: 1

 ✓ src/measuring-rerenders/practice/WordCount.test.tsx > recomputes the word count only when the text itself changes 179ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:45:32
   Duration  24.09s (environment 68%, setup 19%, import 11%, transform 1%, tests 1%)

Two clicks on the unrelated button, zero extra calls to countWords — the same pattern as the main example, applied to a different computation.

3. Pass a child a plain object literal as a prop instead of a function, and confirm memo fails the same way — then fix it with useMemo.

Solution
// src/measuring-rerenders/practice/ObjectProp.tsx
import { memo, useMemo, useState } from "react";

export let panelRenders = 0;

const Panel = memo(function Panel({ style }) {
  panelRenders++;
  return <div style={style}>panel</div>;
});

export function ParentUnstableStyle() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <Panel style={{ color: "blue" }} />
    </div>
  );
}

export function ParentStableStyle() {
  const [count, setCount] = useState(0);
  const style = useMemo(() => ({ color: "blue" }), []);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <Panel style={style} />
    </div>
  );
}
// src/measuring-rerenders/practice/ObjectProp.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ParentUnstableStyle, ParentStableStyle, panelRenders } from "./ObjectProp.js";

it("memo still re-renders when a style object is a new literal every render", async () => {
  const user = userEvent.setup();
  render(<ParentUnstableStyle />);
  const before = panelRenders;

  await user.click(screen.getByRole("button", { name: "+1" }));
  await user.click(screen.getByRole("button", { name: "+1" }));

  console.log(`panelRenders before: ${before}, after two clicks (unstable style): ${panelRenders}`);
  expect(panelRenders).toBe(before + 2);
});

it("useMemo keeps the style object reference stable, so memo actually skips the re-render", async () => {
  const user = userEvent.setup();
  render(<ParentStableStyle />);
  const before = panelRenders;

  await user.click(screen.getByRole("button", { name: "+1" }));
  await user.click(screen.getByRole("button", { name: "+1" }));

  console.log(`panelRenders before: ${before}, after two clicks (stable style): ${panelRenders}`);
  expect(panelRenders).toBe(before);
});
$ npx vitest run measuring-rerenders/practice/ObjectProp.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/practice/ObjectProp.test.tsx > memo still re-renders when a style object is a new literal every render
panelRenders before: 1, after two clicks (unstable style): 3

stdout | src/measuring-rerenders/practice/ObjectProp.test.tsx > useMemo keeps the style object reference stable, so memo actually skips the re-render
panelRenders before: 4, after two clicks (stable style): 4

 ✓ src/measuring-rerenders/practice/ObjectProp.test.tsx > memo still re-renders when a style object is a new literal every render 201ms
 ✓ src/measuring-rerenders/practice/ObjectProp.test.tsx > useMemo keeps the style object reference stable, so memo actually skips the re-render 37ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  06:46:00
   Duration  21.03s (environment 69%, setup 20%, import 9%, transform 1%, tests 1%)

Both tests share the same module-level panelRenders counter, so the second test’s “before” reads 4, not 1 — it’s the 3 left behind by the first test (which already includes ParentUnstableStyle’s mount) plus 1 from ParentStableStyle’s own mount. What matters is each test’s own delta: the unstable-style parent adds 2 more (1 to 3), one per click, exactly like the plain Child from the main example — { color: "blue" } is a new object literal every render, and memo’s Object.is check on style never once finds it equal to the last one. useMemo(() => ({ color: "blue" }), []) fixes it the same way useCallback fixed the function prop: the second test’s count doesn’t move at all.

4. Prove directly that useCallback doesn’t skip creating a new function — it hands back the previous one instead — by collecting every function it returns across several renders and checking they’re all === the same object.

Solution
// src/measuring-rerenders/practice/StableIdentity.tsx
import { useCallback, useState } from "react";

export const seenCallbacks: Array<() => void> = [];

export function IdentityProbe() {
  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => {}, []);
  seenCallbacks.push(handleClick);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </div>
  );
}
// src/measuring-rerenders/practice/StableIdentity.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { IdentityProbe, seenCallbacks } from "./StableIdentity.js";

it("useCallback hands back the same cached function object on every render", async () => {
  const user = userEvent.setup();
  render(<IdentityProbe />);

  await user.click(screen.getByRole("button", { name: "+1" }));
  await user.click(screen.getByRole("button", { name: "+1" }));

  console.log(`seenCallbacks collected: ${seenCallbacks.length}`);
  console.log(`all the same reference: ${seenCallbacks.every((fn) => fn === seenCallbacks[0])}`);
  expect(seenCallbacks).toHaveLength(3);
  expect(new Set(seenCallbacks).size).toBe(1);
});
$ npx vitest run measuring-rerenders/practice/StableIdentity.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/measuring-rerenders/practice/StableIdentity.test.tsx > useCallback hands back the same cached function object on every render
seenCallbacks collected: 3
all the same reference: true

 ✓ src/measuring-rerenders/practice/StableIdentity.test.tsx > useCallback hands back the same cached function object on every render 144ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  06:46:26
   Duration  21.93s (environment 67%, setup 22%, import 8%, transform 1%, tests 1%)

Three renders (the mount plus two clicks) push three entries into seenCallbacks, but new Set(seenCallbacks).size is 1 — every single one is the exact same function object. The arrow function () => {} is still a new value JavaScript creates on every call to IdentityProbe, same as any expression; useCallback evaluates it, throws it away, and returns the one it cached on the first render instead. That’s the mechanism memo’s prop comparison relies on — not that the new function was never made, but that nothing downstream ever sees it.

Frequently Asked Questions

Does memo always stop a component from re-rendering?
No — it stops a re-render only when every prop is shallowly equal (===, or Object.is) to the previous render's props. A new object, array, or function passed as a prop is never equal to the previous one, even if its contents look the same, so memo has no effect unless those props are also kept stable.
Why does wrapping a child in memo sometimes not help at all?
The most common reason is a prop that's a new function or object every render — an inline arrow function passed as onClick, for instance. memo compares that new function to the previous one, finds them unequal, and re-renders anyway. useCallback (or useMemo for objects) keeps that prop's identity stable across renders so memo's comparison can actually succeed.
What does useMemo actually skip?
The function passed to useMemo only runs again when one of its dependencies has changed since the last render; otherwise useMemo returns the cached result from last time without calling the function at all. It's for avoiding an expensive recomputation, not for avoiding a re-render — the component itself still re-renders.
Should I wrap everything in memo, useMemo, and useCallback?
No — each one has its own small overhead (an extra comparison, a cache slot), and most components are cheap enough that re-rendering them is not a measurable problem. They earn their place on a specific, measured case: an expensive computation, or a child that's visibly re-rendering more than its props justify.