3 memo defeated
GOAL
watch a memo()'d skill node redraw on every parent pass because its props are built inline, then win the skip back with stable identities.
CONCEPT
memo(Component) skips a redraw only when every incoming prop passes an Object.is check against the prop it had last time. An object literal or an arrow function written inside JSX is a brand-new identity on each parent pass, so that check can never hold and the memo() never saves a thing.
HINT
two repairs exist - build the value once (useMemo for the object, useCallback for the handler), or hoist it above the component entirely. Either way, Object.is has to meet the same reference twice.
MIRRORS
the HUD's skill tree, where every branch skill and every leaf skill is memo()'d so spending one point redraws one node instead of the whole tree; a single inline prop undoes the memo on all of them and the HUD redraws top to bottom again.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 08-render-detection/03-memo-defeated.tsxSource
// DOJO · Module 8 / Exercise 3 — memo defeated
// GOAL: watch a memo()'d skill node redraw on every parent pass because
// its props are built inline, then win the skip back with stable
// identities.
// CONCEPT: memo(Component) skips a redraw only when every incoming prop
// passes an Object.is check against the prop it had last time. An
// object literal or an arrow function written inside JSX is a
// brand-new identity on each parent pass, so that check can never
// hold and the memo() never saves a thing.
// HINT: two repairs exist - build the value once (useMemo for the
// object, useCallback for the handler), or hoist it above the
// component entirely. Either way, Object.is has to meet the same
// reference twice.
// MIRRORS: the HUD's skill tree, where every branch skill and every
// leaf skill is memo()'d so spending one point redraws one node
// instead of the whole tree; a single inline prop undoes the memo
// on all of them and the HUD redraws top to bottom again.
// Run: pnpm koan 08-render-detection/03-memo-defeated.tsx
import { memo, useCallback, useMemo, useState } from 'react';
import { afterEach, expect, it } from 'vitest';
import {
cleanup,
fireEvent,
render,
screen,
} from '@testing-library/react';
// HUD redraw tally: every component bumps its own name at the top of
// its body, so a test can ask how often the HUD redrew that node.
const redraws = new Map<string, number>();
function drew(name: string): void {
redraws.set(name, (redraws.get(name) ?? 0) + 1);
}
function redrawsOf(name: string): number {
return redraws.get(name) ?? 0;
}
afterEach(() => {
cleanup();
redraws.clear();
});
interface SkillProps {
rank: { tier: string };
onLearn: () => void;
}
const LeakyLeafSkill = memo(function LeakyLeafSkill(p: SkillProps) {
drew('LeakyLeafSkill');
return (
<button onClick={p.onLearn}>{`leaky tier: ${p.rank.tier}`}</button>
);
});
// The bug specimen. It stays broken on purpose - TODO 1 only asks you
// to predict what it costs.
function LeakyBranchSkill() {
drew('LeakyBranchSkill');
const [, setPoints] = useState(0);
return (
<div>
<button onClick={() => setPoints((p) => p + 1)}>
leaky spend
</button>
{/* both props are built fresh on every branch pass */}
<LeakyLeafSkill rank={{ tier: 'bronze' }} onLearn={() => {}} />
</div>
);
}
// -- TODO 1 -----------------------------------------------------------
// LeakyLeafSkill is memo()'d, yet its rank object and its onLearn
// function are new identities on every LeakyBranchSkill pass. The test
// mounts LeakyBranchSkill and clicks 'leaky spend' twice, so the branch
// itself runs 3 times in total. Replace null with the number of times
// the HUD redrew LeakyLeafSkill.
const LEAKY_LEAF_REDRAWS: number | null = null;
const FixedLeafSkill = memo(function FixedLeafSkill(p: SkillProps) {
drew('FixedLeafSkill');
return (
<button onClick={p.onLearn}>{`fixed tier: ${p.rank.tier}`}</button>
);
});
function FixedBranchSkill() {
drew('FixedBranchSkill');
const [, setPoints] = useState(0);
// -- TODO 2 ---------------------------------------------------------
// LeakyBranchSkill's bug, copied. Give both props identities that
// survive a redraw - useMemo for the object, useCallback for the
// handler, or hoist them above the component - so FixedLeafSkill's
// memo() can actually skip.
const rank = { tier: 'gold' };
const onLearn = (): void => {};
return (
<div>
<button onClick={() => setPoints((p) => p + 1)}>
fixed spend
</button>
<FixedLeafSkill rank={rank} onLearn={onLearn} />
</div>
);
}
it('TODO 1 — inline props defeat memo on every redraw', () => {
render(<LeakyBranchSkill />);
const spend = screen.getByRole('button', { name: 'leaky spend' });
fireEvent.click(spend);
fireEvent.click(spend);
// mount + 2 clicks, given
expect(redrawsOf('LeakyBranchSkill')).toBe(3);
// TODO 1: how many times did the HUD redraw the leaf?
expect(redrawsOf('LeakyLeafSkill')).toBe(LEAKY_LEAF_REDRAWS);
});
it('TODO 2 — stable identities let memo skip', () => {
render(<FixedBranchSkill />);
const spend = screen.getByRole('button', { name: 'fixed spend' });
fireEvent.click(spend);
fireEvent.click(spend);
// mount + 2 clicks
expect(redrawsOf('FixedBranchSkill')).toBe(3);
// TODO 2: drawn once at mount, then skipped both times
expect(redrawsOf('FixedLeafSkill')).toBe(1);
});