Deepen PracticeOrdered learning track

Memoization Decision Framework

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 082

Decision framework untuk memoization React production: kapan memakai memo, useMemo, useCallback, selector memoization, context value memoization, custom equality, React Compiler, dan kapan memoization justru salah.

13 min read2401 words
PrevNext
Lesson 82123 lesson track68–101 Deepen Practice
#react#hooks#memoization#usememo+5 more

Part 082 — Memoization Decision Framework

Memoization adalah cache.

Cache selalu punya trade-off:

lebih sedikit recomputation
vs
lebih banyak complexity, dependency risk, stale data risk, memory cost, dan debugging cost

Di React, memoization sering disalahgunakan karena terlihat mudah:

const value = useMemo(() => expensiveThing(input), [input]);
const onClick = useCallback(() => doThing(input), [input]);
export default memo(Component);

Tetapi pertanyaan yang benar bukan “bisa dimemoize atau tidak?”.

Pertanyaan yang benar:

Apa yang ingin dicegah?
Recalculation?
Child re-render?
Effect resubscription?
Context fan-out?
Selector churn?
Expensive object allocation?
Reference instability across boundary?

Tanpa jawaban itu, memoization biasanya berubah menjadi ritual.


1. Target Mental Model

Memoization menyimpan hasil berdasarkan dependency atau input comparison.

Dalam React, ada beberapa lapisan memoization:

React.memo             skip component render if props equal
useMemo                cache computed value
useCallback            cache function identity
selector memoization   cache derived data from store/query
context value memo     stabilize provider value
query cache            cache server state by query key
React Compiler         automatic memoization where valid

Masing-masing menyelesaikan masalah berbeda.


2. Golden Rule

Memoization adalah performance optimization, bukan correctness mechanism.

Jika aplikasi rusak tanpa useMemo, useCallback, atau memo, maka kemungkinan ada bug desain:

  • effect dependency salah,
  • state derivation salah,
  • callback contract salah,
  • mutation object/reference salah,
  • subscription cleanup salah,
  • component tidak pure,
  • external store snapshot tidak stabil,
  • code bergantung pada identity untuk correctness.

Memoization boleh membantu stabilitas identity untuk effect atau boundary tertentu, tetapi invariant aplikasi tidak boleh bergantung pada “kebetulan cache”.


3. Jenis Memoization dan Masalah yang Diselesaikan

ToolMencegahCocok SaatTidak Cocok Saat
useMemorecalculation / unstable object identitykalkulasi mahal, object prop ke memo child, dependency effect stabilkalkulasi murah, dependency selalu berubah
useCallbackfunction identity berubahcallback dikirim ke memo child, dependency effect/subscriptionhandler lokal biasa
memochild render dengan props samachild render mahal dan props stabilprops selalu baru, child murah
context value memosemua consumer render karena value object baruprovider value object/functioncontext terlalu besar/volatile
selector memorepeated derived datastore/query derived data mahalselector murah, input selalu berubah
custom equalityskip update dengan perbandingan khususdata shape kecil dan comparison amandeep compare besar, function props
React Compilerautomatic memoizationcode pure dan compiler-compatibleimpure render, dynamic escape hatches

4. Decision Tree

Kalau bottleneck bukan recalculation atau render skip, memoization mungkin bukan alat yang benar.


5. useMemo: Cache Computed Value

useMemo menyimpan hasil kalkulasi antar render selama dependency sama.

Contoh valid:

const visibleRows = useMemo(() => {
  return rows
    .filter(row => matchesFilters(row, filters))
    .sort(compareRows);
}, [rows, filters]);

Valid jika:

  • rows besar,
  • filter/sort mahal,
  • component sering render karena input lain,
  • rows dan filters punya identity stabil yang benar,
  • hasil dipakai oleh expensive subtree.

Tidak perlu:

const fullName = useMemo(() => {
  return `${user.firstName} ${user.lastName}`;
}, [user.firstName, user.lastName]);

String concatenation murah. Ini menambah noise.


6. useMemo untuk Stabilkan Object Prop

Kadang useMemo bukan untuk menghemat kalkulasi, tetapi untuk menjaga identity object.

const chartOptions = useMemo(
  () => ({
    density,
    showLegend,
    currency,
  }),
  [density, showLegend, currency]
);

return <MemoizedChart data={data} options={chartOptions} />;

Ini valid jika:

  • MemoizedChart memakai memo,
  • chart render mahal,
  • options berubah jarang,
  • tanpa stable options, memo boundary selalu miss.

Tetapi jika child tidak memoized atau murah, useMemo ini tidak memberi banyak nilai.


7. useMemo untuk Effect Dependency

Kadang object dependency membuat effect resubscribe terus.

Buruk:

function ChatRoom({ roomId }: { roomId: string }) {
  const options = { serverUrl: 'wss://chat.example.com', roomId };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]);
}

options selalu baru, effect reconnect setiap render.

Perbaikan paling sederhana: pindahkan object ke dalam effect.

function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const options = { serverUrl: 'wss://chat.example.com', roomId };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

Jika object memang perlu dibagi ke beberapa tempat, baru pertimbangkan useMemo.

const options = useMemo(
  () => ({ serverUrl: 'wss://chat.example.com', roomId }),
  [roomId]
);

Preferensi:

Remove unnecessary dependency > move object/function inside effect > useMemo/useCallback for stable dependency

8. useCallback: Cache Function Identity

useCallback(fn, deps) secara konseptual sama dengan:

useMemo(() => fn, deps)

Ia menyimpan function identity.

Valid:

const handleToggle = useCallback((id: string) => {
  dispatch({ type: 'toggle', id });
}, []);

return <MemoizedTable rows={rows} onToggle={handleToggle} />;

Valid jika:

  • callback melewati memo boundary,
  • child render mahal,
  • child membandingkan props secara shallow,
  • dependency callback stabil.

Tidak perlu:

<button onClick={useCallback(() => setOpen(true), [])}>Open</button>

Handler lokal pada element biasa tidak perlu useCallback.


9. Functional Updater untuk Mengurangi Dependency

Buruk:

const addTodo = useCallback((text: string) => {
  setTodos([...todos, { id: createId(), text }]);
}, [todos]);

addTodo berubah setiap todos berubah.

Lebih baik:

const addTodo = useCallback((text: string) => {
  setTodos(current => [...current, { id: createId(), text }]);
}, []);

Functional updater membuat callback tidak perlu membaca snapshot todos dari closure.

Ini bukan hack. Ini dependency correctness.


10. memo: Skip Component Render

memo membuat React dapat melewati render component jika props sama menurut shallow comparison.

const UserRow = memo(function UserRow({ user, selected, onSelect }: Props) {
  return (
    <tr>
      <td>{user.name}</td>
      <td>
        <input
          type="checkbox"
          checked={selected}
          onChange={() => onSelect(user.id)}
        />
      </td>
    </tr>
  );
});

Valid jika:

  • component sering menerima props yang sama,
  • render component mahal,
  • parent sering render karena alasan lain,
  • props stabil,
  • component pure.

Tidak berguna jika:

<UserRow
  user={{ id: user.id, name: user.name }}
  selected={selectedIds.includes(user.id)}
  onSelect={() => selectUser(user.id)}
/>

Object dan function selalu baru.


11. Custom Equality pada memo

React memo bisa menerima custom compare.

const Chart = memo(
  function Chart({ points, width, height }: Props) {
    return <CanvasChart points={points} width={width} height={height} />;
  },
  (prev, next) => {
    return (
      prev.points === next.points &&
      prev.width === next.width &&
      prev.height === next.height
    );
  }
);

Custom compare valid jika:

  • props shape jelas,
  • comparison lebih murah dari render,
  • semua props termasuk functions dibanding dengan benar,
  • tidak deep compare struktur besar setiap update,
  • tidak menyembunyikan mutation bug.

Bahaya:

(prev, next) => prev.user.id === next.user.id

Jika user.name berubah tetapi id sama, UI stale.

Custom equality adalah kontrak correctness. Jangan menulisnya longgar.


12. Memoization dan Mutation

Memoization mengasumsikan immutable update/identity meaningful.

Buruk:

user.name = 'Alice';
setUser(user);

React dan memo boundary bisa melihat reference yang sama. UI bisa stale.

Benar:

setUser(current => ({ ...current, name: 'Alice' }));

Untuk nested data:

setState(current => ({
  ...current,
  users: {
    ...current.users,
    [id]: {
      ...current.users[id],
      name: 'Alice',
    },
  },
}));

Structural sharing membuat memoization punya sinyal yang benar.


13. Memoization dan Children

children juga props.

const Panel = memo(function Panel({ children }: { children: React.ReactNode }) {
  return <section>{children}</section>;
});

function Parent() {
  return (
    <Panel>
      <ExpensiveChild />
    </Panel>
  );
}

Apakah Panel bisa skip? Tergantung identity children yang diterima.

Jangan mengandalkan memo pada wrapper dengan dynamic children sebagai optimization utama. Lebih baik pindahkan state agar wrapper tidak render, atau buat boundary lebih tepat.


14. Memoization dan Context

memo tidak mencegah render akibat context yang dibaca component berubah.

const UserBadge = memo(function UserBadge() {
  const user = useContext(AuthContext);
  return <span>{user.name}</span>;
});

Jika AuthContext value berubah, UserBadge render walaupun memo dipakai.

Refactor jika hanya sebagian context dibutuhkan:

function UserBadgeContainer() {
  const userName = useUserName();
  return <UserBadge userName={userName} />;
}

const UserBadge = memo(function UserBadge({ userName }: { userName: string }) {
  return <span>{userName}</span>;
});

Atau gunakan context splitting / selector store jika granularity dibutuhkan.


15. Context Value Memoization

Provider value object harus stabil jika provider sering render.

Buruk:

return (
  <ThemeContext.Provider value={{ theme, setTheme }}>
    {children}
  </ThemeContext.Provider>
);

Lebih baik:

const value = useMemo(() => ({ theme, setTheme }), [theme]);

return (
  <ThemeContext.Provider value={value}>
    {children}
  </ThemeContext.Provider>
);

Namun jangan berhenti di sini jika context terlalu besar.

Memoizing provider value fixes accidental identity churn.
It does not fix bad context domain design.

Jika theme berubah, semua consumer tetap render. Itu benar. Jika terlalu banyak consumer terdampak, split context atau pindah ke selector store.


16. Selector Memoization

Selector memoization menyimpan derived data dari store/cache.

Contoh tanpa memo:

function selectVisibleCases(state: CaseState, filters: Filters) {
  return state.caseIds
    .map(id => state.cases[id])
    .filter(item => matches(item, filters));
}

Jika dipanggil oleh banyak component, kalkulasi diulang.

Memoized selector:

function createVisibleCasesSelector() {
  let previousCases: CaseState['cases'] | null = null;
  let previousIds: string[] | null = null;
  let previousFilters: Filters | null = null;
  let previousResult: Case[] = [];

  return function selectVisibleCases(state: CaseState, filters: Filters) {
    if (
      previousCases === state.cases &&
      previousIds === state.caseIds &&
      previousFilters === filters
    ) {
      return previousResult;
    }

    previousCases = state.cases;
    previousIds = state.caseIds;
    previousFilters = filters;
    previousResult = state.caseIds
      .map(id => state.cases[id])
      .filter(item => matches(item, filters));

    return previousResult;
  };
}

Selector memoization valid jika:

  • derived calculation mahal,
  • input identity meaningful,
  • selector instance lifetime benar,
  • result identity dipakai untuk skip downstream render.

17. Per-Component Selector Instance

Jika selector menerima props berbeda per component, jangan selalu pakai satu global selector cache.

Buruk:

const selectCaseById = createSelector(
  [(state: State) => state.cases, (_: State, id: string) => id],
  (cases, id) => cases[id]
);

Jika dipakai oleh banyak id bergantian, cache satu-entry bisa churn.

Per-component selector:

function CaseRow({ id }: { id: string }) {
  const selectCase = useMemo(() => createCaseSelector(), []);
  const item = useStore(state => selectCase(state, id));

  return <Row item={item} />;
}

Atau gunakan store selector yang langsung memilih primitive/small slice.


18. Query Selector / select Option

Server-state cache juga bisa punya selector.

const caseTitle = useQuery({
  queryKey: caseKeys.detail(caseId),
  queryFn: () => fetchCase(caseId),
  select: data => data.title,
});

Ini berguna jika component hanya butuh sebagian data.

Namun tetap ingat:

  • query key menentukan cache entry,
  • select menentukan projection untuk observer,
  • invalidation tetap berdasarkan query key,
  • selector harus pure,
  • expensive select mungkin perlu stabil/memoized tergantung library behavior dan data identity.

19. Memoization dan React Compiler

React Compiler melakukan automatic memoization pada component/hook yang dapat dianalisis dan memenuhi rules/purity.

Implikasi:

Kurangi blanket useMemo/useCallback/React.memo.
Tulis komponen pure dan jelas.
Gunakan manual memoization sebagai escape hatch yang disengaja.
Jangan bergantung pada memoization untuk correctness.

Manual memoization tetap relevan untuk:

  • stabilizing dependency effect tertentu,
  • library boundary yang membutuhkan stable reference,
  • external subscription API,
  • expensive custom comparison yang compiler tidak bisa infer,
  • non-React cache lifecycle,
  • selector instance lifetime,
  • documented public API stability.

Tetapi memoization manual yang berlebihan bisa melawan keterbacaan dan menyembunyikan desain state yang salah.


20. React Compiler Compatibility Mindset

Agar compiler dan manual optimization bekerja, component harus pure:

No mutation during render.
No side effect during render.
No dependency on render call count.
No conditional Hook call.
No hidden mutable global read that changes unpredictably.
No memoization for correctness.

Buruk:

const cache: string[] = [];

function Component({ value }: { value: string }) {
  cache.push(value);
  return <div>{cache.length}</div>;
}

Render punya side effect. Concurrent rendering dan compiler optimization bisa membuat hasil sulit diprediksi.

Benar:

function Component({ value }: { value: string }) {
  return <div>{value}</div>;
}

Memoization bukan solusi untuk impurity. Hapus impurity.


21. Dependency Array Correctness

Dependency array bukan daftar manual yang boleh dipilih sesuka hati.

Ia menggambarkan reactive values yang dibaca.

Buruk:

const submit = useCallback(() => {
  api.save(form);
}, []); // form hilang dari dependency

Callback menyimpan stale form.

Benar:

const submit = useCallback(() => {
  api.save(form);
}, [api, form]);

Atau ubah desain:

const submit = useCallback((form: FormState) => {
  api.save(form);
}, [api]);

Dependency dapat dikurangi dengan mengubah API, bukan dengan menghapus dependency secara paksa.


22. Cost Model: Memoization Tidak Gratis

Memoization punya biaya:

dependency comparison
cache storage
extra code
mental overhead
possible stale closure
possible memory retention
possible custom equality bug
possible false negative/positive skip

Contoh useMemo yang lebih mahal dari kalkulasinya:

const isActive = useMemo(() => id === activeId, [id, activeId]);

Cukup:

const isActive = id === activeId;

Gunakan memoization ketika biaya recomputation/render lebih besar dari biaya memoization dan complexity-nya.


23. Memory Retention Risk

Memoization menyimpan reference.

const indexed = useMemo(() => buildHugeIndex(data), [data]);

Jika data besar dan indexed besar, memory footprint naik. Ini mungkin benar, tetapi harus disengaja.

Pertanyaan:

Berapa ukuran input?
Berapa ukuran output cache?
Berapa lama component hidup?
Apakah cache harus invalidated?
Apakah data bisa dipindah ke worker/store/server?

Memoization bisa mempercepat CPU dengan menukar memory.


24. Stale Closure Risk

useCallback sering menciptakan bug stale closure jika dependency dihapus.

Buruk:

const handleSave = useCallback(async () => {
  await save(draft);
  setStatus(`Saved ${draft.title}`);
}, []);

draft stale.

Benar:

const handleSave = useCallback(async () => {
  await save(draft);
  setStatus(`Saved ${draft.title}`);
}, [draft]);

Atau command menerima payload:

const saveDraft = useCallback(async (nextDraft: Draft) => {
  await save(nextDraft);
  setStatus(`Saved ${nextDraft.title}`);
}, []);

API command yang menerima payload sering lebih stabil daripada callback yang membaca terlalu banyak closure.


25. Stable Callback Pattern with Ref

Kadang external system membutuhkan stable subscription callback tetapi callback perlu membaca latest state.

Pattern:

function useLatest<T>(value: T) {
  const ref = useRef(value);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref;
}

function useWindowEvent<K extends keyof WindowEventMap>(
  type: K,
  handler: (event: WindowEventMap[K]) => void
) {
  const handlerRef = useLatest(handler);

  useEffect(() => {
    function listener(event: WindowEventMap[K]) {
      handlerRef.current(event);
    }

    window.addEventListener(type, listener);
    return () => window.removeEventListener(type, listener);
  }, [type, handlerRef]);
}

Ini adalah escape hatch untuk external subscription. Jangan pakai untuk menyembunyikan dependency internal render biasa.


26. Memoization dan API Design

Kadang callback instability adalah gejala API yang salah.

Buruk:

<ItemRow
  item={item}
  onDelete={() => deleteItem(item.id)}
  onArchive={() => archiveItem(item.id)}
  onAssign={(userId) => assignItem(item.id, userId)}
/>

Setiap row menerima function baru.

Alternatif:

<ItemRow
  item={item}
  commands={commands}
/>

Dengan commands stabil:

const commands = useMemo(() => ({
  deleteItem,
  archiveItem,
  assignItem,
}), [deleteItem, archiveItem, assignItem]);

Atau row callback menerima id:

<ItemRow
  item={item}
  onDelete={deleteItem}
  onArchive={archiveItem}
  onAssign={assignItem}
/>

Di dalam row:

<button onClick={() => onDelete(item.id)}>Delete</button>

Function inline masih ada di row, tetapi parent-to-child prop identity lebih stabil.


27. Memoization dan Component Boundary

Kadang memoization lebih efektif jika component dipecah.

Buruk:

function CasePanel({ caseData, comments, permissions, filters }: Props) {
  const visibleComments = useMemo(
    () => filterComments(comments, filters),
    [comments, filters]
  );

  return (
    <section>
      <CaseHeader caseData={caseData} />
      <PermissionToolbar permissions={permissions} />
      <CommentList comments={visibleComments} />
    </section>
  );
}

Jika filters berubah, whole CasePanel render.

Lebih baik:

function CasePanel({ caseData, comments, permissions }: Props) {
  return (
    <section>
      <CaseHeader caseData={caseData} />
      <PermissionToolbar permissions={permissions} />
      <CommentSearch comments={comments} />
    </section>
  );
}

function CommentSearch({ comments }: { comments: Comment[] }) {
  const [filters, setFilters] = useState(defaultFilters);
  const visibleComments = useMemo(
    () => filterComments(comments, filters),
    [comments, filters]
  );

  return <CommentList comments={visibleComments} onFilter={setFilters} />;
}

Boundary first, memo second.


28. Memoization dan Large Lists

For large lists, there are three different problems:

Too much derived work
Too many row renders
Too many DOM nodes

Solutions differ:

ProblemTool
filter/sort expensiveuseMemo, server query, worker
row re-render too oftenmemo, stable props, selector per row
DOM too largevirtualization/windowing

Do not solve 10.000 DOM nodes with useMemo only.


29. Memoization dan Form

Forms can have many fields. Bad memoization can create stale validation; no memoization can create render fan-out.

Good pattern:

const Field = memo(function Field({
  name,
  value,
  error,
  onChange,
}: FieldProps) {
  return (
    <label>
      {name}
      <input value={value} onChange={event => onChange(name, event.target.value)} />
      {error && <span>{error}</span>}
    </label>
  );
});

Parent stabilizes command:

const setFieldValue = useCallback((name: string, value: string) => {
  dispatch({ type: 'field.changed', name, value });
}, []);

Each field receives primitive value and error. Memo boundary can work.

Bad pattern:

<Field
  field={{ name, value, error }}
  onChange={(value) => setFieldValue(name, value)}
/>

Object/function props churn.


30. Memoization dan Permissions

Permission checks can be expensive if repeatedly computed from large policy graphs.

Bad:

const canApprove = computePermission(user, caseData, policy, 'approve');

If expensive and repeated:

const canApprove = useMemo(
  () => computePermission(user, caseData, policy, 'approve'),
  [user, caseData, policy]
);

But better architecture might be:

const canApprove = usePermission(caseId, 'approve');

Where permission snapshot is:

  • returned by backend,
  • cached by query key,
  • selected by external store,
  • or injected as capability context.

Memoization should not hide authorization uncertainty. UI permission is not security boundary.


31. Memoization dan External Store Selectors

Bad:

const item = useStore(state => ({
  name: state.items[id].name,
  status: state.items[id].status,
}));

This returns a new object every time selector runs.

Options:

const name = useStore(state => state.items[id].name);
const status = useStore(state => state.items[id].status);

Or:

const item = useStore(
  state => ({
    name: state.items[id].name,
    status: state.items[id].status,
  }),
  shallow
);

Selector output identity matters as much as input identity.


32. Memoization and Server State Projection

If server returns a large entity and many small components consume fields, avoid passing the entire object everywhere.

Bad:

<CaseHeader case={caseQuery.data} />
<CaseStatus case={caseQuery.data} />
<CaseOwner case={caseQuery.data} />

Each component receives large object. Any object identity change can cause all to render.

Better:

<CaseHeader title={caseData.title} reference={caseData.reference} />
<CaseStatus status={caseData.status} />
<CaseOwner ownerName={caseData.owner.name} />

Or use query select/external store selectors if data is shared broadly.


33. Anti-Pattern: Memoizing Everything

Bad:

const a = useMemo(() => props.a, [props.a]);
const b = useMemo(() => props.b + 1, [props.b]);
const c = useCallback(() => setOpen(true), []);
const d = useMemo(() => ({ label: 'Save' }), []);

Problems:

  • noisy code,
  • review fatigue,
  • dependency risk,
  • no measured value,
  • makes real performance boundaries harder to see.

Use memoization where it marks a real boundary.


34. Anti-Pattern: Deep Compare Everything

Bad:

const Chart = memo(ChartImpl, (prev, next) => {
  return JSON.stringify(prev) === JSON.stringify(next);
});

Problems:

  • expensive comparison,
  • ignores function semantics,
  • order/key issues,
  • breaks on non-serializable values,
  • may cost more than render,
  • hides mutation problems.

Prefer stable identity and structural sharing.


35. Anti-Pattern: Lying to Dependency Array

Bad:

const value = useMemo(() => compute(a, b), [a]);

This is not optimization. It is stale data.

If b should not cause recompute, change the calculation or state model. Do not omit dependency.


36. Anti-Pattern: Memoization for Effect Control

Bad:

const stableOptions = useMemo(() => options, []);

useEffect(() => {
  connect(stableOptions);
}, [stableOptions]);

If options changes but effect should not reconnect, define which option fields are actually reactive.

useEffect(() => {
  connect({ roomId, serverUrl });
}, [roomId, serverUrl]);

If some values are non-reactive runtime configuration, place them outside component or in stable capability context.


37. Anti-Pattern: Custom Equality Ignores Callback

Bad:

const Button = memo(ButtonImpl, (prev, next) => {
  return prev.label === next.label;
});

If onClick changes but equality ignores it, button may call stale function.

Correct custom equality must compare every prop whose value affects output or behavior.

const Button = memo(ButtonImpl, (prev, next) => {
  return prev.label === next.label && prev.onClick === next.onClick;
});

38. Production Memoization Review Template

## Memoization Review

### Problem
- What is slow?
- Is it user-visible?
- Profiling evidence:

### Boundary
- Are we preventing recalculation or re-render?
- Which component/calculation is expensive?
- What is the expected skip rate?

### Inputs
- Are dependencies complete?
- Are input identities stable?
- Are objects/functions recreated unnecessarily?

### Correctness
- Would the app still be correct if memoization were removed?
- Any stale closure risk?
- Any mutation risk?
- Any custom equality risk?

### Alternatives
- Move state down?
- Split component?
- Split context?
- Use selector store?
- Virtualize list?
- Use transition/deferred?
- Fix query key/invalidation?

### Measurement
- Before:
- After:
- Net improvement:

39. Decision Matrix

SituationRecommended Action
Cheap calculationNo useMemo
Expensive calculation with stable depsuseMemo
Object prop breaks memo childuseMemo object or change prop shape
Callback passed to normal DOM elementNo useCallback needed
Callback passed to memoized expensive childuseCallback
Child expensive, props often samememo
Child cheapNo memo
Context value object recreateduseMemo provider value
Context too broadSplit context before memo tricks
Store selector returns objectreturn primitive or use equality/memo selector
Large list DOM hugeVirtualize, memo row later
Input lag from result renderinguseDeferredValue / transition
Network too frequentdebounce/query policy, not useMemo only
Effect reconnects due object depmove object inside effect or memo carefully
Manual memo everywhereremove unless measured/justified
Compiler enabledprefer pure code, manual memo only as escape hatch

40. Build From Scratch: Tiny Memo Cache

To understand useMemo, build a tiny conceptual cache.

function createMemoCell<T>() {
  let initialized = false;
  let previousDeps: unknown[] = [];
  let previousValue: T;

  return function memoize(factory: () => T, deps: unknown[]) {
    if (initialized && depsEqual(previousDeps, deps)) {
      return previousValue;
    }

    initialized = true;
    previousDeps = deps;
    previousValue = factory();
    return previousValue;
  };
}

function depsEqual(a: unknown[], b: unknown[]) {
  if (a.length !== b.length) return false;

  for (let i = 0; i < a.length; i += 1) {
    if (!Object.is(a[i], b[i])) return false;
  }

  return true;
}

This model explains three things:

  1. dependencies must be complete,
  2. object/function identity matters,
  3. cache is per hook position/component instance.

41. Build From Scratch: Shallow Compare for memo

Conceptual shallow props comparison:

function shallowEqualObjects(
  previous: Record<string, unknown>,
  next: Record<string, unknown>
) {
  const previousKeys = Object.keys(previous);
  const nextKeys = Object.keys(next);

  if (previousKeys.length !== nextKeys.length) {
    return false;
  }

  for (const key of previousKeys) {
    if (!Object.prototype.hasOwnProperty.call(next, key)) {
      return false;
    }

    if (!Object.is(previous[key], next[key])) {
      return false;
    }
  }

  return true;
}

This explains why these props break memo boundary:

<Component options={{ dense: true }} onClick={() => save()} />

Both object and function are new each render.


42. Refactor Recipe: Remove Bad Memoization

Before:

function UserCard({ user }: { user: User }) {
  const fullName = useMemo(
    () => `${user.firstName} ${user.lastName}`,
    [user.firstName, user.lastName]
  );

  const handleClick = useCallback(() => {
    console.log(user.id);
  }, [user.id]);

  return <button onClick={handleClick}>{fullName}</button>;
}

After:

function UserCard({ user }: { user: User }) {
  const fullName = `${user.firstName} ${user.lastName}`;

  return (
    <button onClick={() => console.log(user.id)}>
      {fullName}
    </button>
  );
}

This is better if no expensive memo child or measured bottleneck exists.


43. Refactor Recipe: Make Memo Useful

Before:

const Chart = memo(ChartImpl);

function Dashboard({ data, currency }: Props) {
  return (
    <Chart
      data={data}
      options={{ currency, showLegend: true }}
      onPointClick={(point) => console.log(point.id)}
    />
  );
}

Chart memo likely misses.

After:

const Chart = memo(ChartImpl);

function Dashboard({ data, currency }: Props) {
  const options = useMemo(
    () => ({ currency, showLegend: true }),
    [currency]
  );

  const handlePointClick = useCallback((point: Point) => {
    console.log(point.id);
  }, []);

  return (
    <Chart
      data={data}
      options={options}
      onPointClick={handlePointClick}
    />
  );
}

Now memo boundary has stable props.

Still measure. If chart is cheap, this complexity may not be worth it.


44. Refactor Recipe: Replace Memo with State Topology

Before:

function Page() {
  const [draft, setDraft] = useState('');

  return (
    <>
      <HeavySidebar />
      <Editor draft={draft} onChange={setDraft} />
      <HeavyAuditPanel />
    </>
  );
}

const HeavySidebar = memo(HeavySidebarImpl);
const HeavyAuditPanel = memo(HeavyAuditPanelImpl);

After:

function Page() {
  return (
    <>
      <HeavySidebar />
      <EditorBoundary />
      <HeavyAuditPanel />
    </>
  );
}

function EditorBoundary() {
  const [draft, setDraft] = useState('');
  return <Editor draft={draft} onChange={setDraft} />;
}

State topology removes the need to protect unrelated siblings with memo.


45. Refactor Recipe: Context Split Before Memo

Before:

const AppContext = createContext<AppContextValue | null>(null);

function AppProvider({ children }: PropsWithChildren) {
  const [sidebarOpen, setSidebarOpen] = useState(false);
  const [theme, setTheme] = useState('dark');
  const [user, setUser] = useState<User | null>(null);

  const value = useMemo(
    () => ({ sidebarOpen, setSidebarOpen, theme, setTheme, user, setUser }),
    [sidebarOpen, theme, user]
  );

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

useMemo stabilizes object only when fields do not change. It does not stop theme consumers from rerendering when sidebar changes if they read same context value.

After:

<AuthProvider>
  <ThemeProvider>
    <SidebarProvider>{children}</SidebarProvider>
  </ThemeProvider>
</AuthProvider>

Split by domain and volatility.


46. Refactor Recipe: Selector Instead of Memo Child

Before:

function Row({ id }: { id: string }) {
  const state = useStore();
  const item = state.items[id];

  return <RowView item={item} />;
}

After:

function Row({ id }: { id: string }) {
  const item = useStore(state => state.items[id]);
  return <RowView item={item} />;
}

If item identity is structurally shared, row only updates when that item changes.


47. Memoization Checklist

Before adding memoization, answer:

1. What user-visible issue exists?
2. What measurement supports it?
3. Are we reducing render, calculation, or identity churn?
4. Are dependencies complete?
5. Are input identities stable?
6. Is memoization cheaper than recomputation/render?
7. Would app remain correct without memoization?
8. Is there a state topology fix instead?
9. Is there a context split or selector fix instead?
10. Did we measure after?

If you cannot answer these, do not add memoization yet.


48. Engineering Heuristics

Do not memoize cheap calculations.
Do not use useCallback for local DOM handlers by default.
Do not add memo to every component.
Do not omit dependencies to keep identity stable.
Do not use deep compare as default strategy.
Do not rely on memoization for correctness.

Do memoize expensive pure calculations with stable dependencies.
Do stabilize object/function props that cross expensive memo boundaries.
Do split state and context before memoizing broadly.
Do use selectors for granular external store reads.
Do use structural sharing to make identity meaningful.
Do measure before and after.
Do keep manual memoization intentional in React Compiler codebases.

49. Practical Exercises

Exercise 1 — Memoization audit

Pick one React module and classify every useMemo, useCallback, and memo:

location:
reason:
prevents:
deps complete:
measured value:
can remove:

Remove the ones that exist only by habit.

Exercise 2 — Make one memo boundary actually work

Find one expensive child wrapped in memo. Log which props change. Stabilize only the props that break the boundary.

Exercise 3 — Replace memoization with state co-location

Find one parent that uses memoized siblings to protect them from local state changes. Move the state into a smaller boundary and remove unnecessary memoization.

Exercise 4 — Selector output identity

Find one store selector that returns object/array. Decide:

Can it return primitive?
Can it return existing object reference?
Does it need shallow equality?
Does it need memoized selector instance?

50. Summary

Memoization is not a philosophy. It is a targeted cache.

Use it when you know:

what is expensive,
which identity must be stable,
which boundary should skip,
which dependencies define correctness,
and how to prove improvement.

Default architecture order:

state topology
component boundary
context/domain split
selector subscription
query/cache design
list virtualization
transition/deferred scheduling
memoization

In modern React, especially with React Compiler, the best code is not the code with the most useMemo and useCallback.

The best code is pure, well-owned, low-radius, easy to profile, and memoized only where the boundary is real.

Lesson Recap

You just completed lesson 82 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.