4 collapse amnesia
GOAL
first work out what an unmount costs a branch that keeps its own useState, then move every open flag up into one Map held by a parent that an unmount can never reach.
CONCEPT
an instance that unmounts takes its hook state with it, and when it mounts again each useState initializer is evaluated from scratch. Park the state somewhere that never unmounts and the amnesia has nowhere to happen.
HINT
a Map you mutate in place is still the very same object, and the same object means no re-render. Hand setOpenById a freshly built Map instead.
MIRRORS
a skill tree whose HUD parks every branch's open flag in a quest-log store that sits outside the branches, keyed by skill id. Collapsing a branch unmounts the skills beneath it, yet the tree reopens exactly as the player left it.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 07-recursive-components/04-collapse-amnesia.tsxSource
// DOJO · Module 7 / Exercise 4 — collapse amnesia
// GOAL: first work out what an unmount costs a branch that keeps its
// own useState, then move every open flag up into one Map
// held by a parent that an unmount can never reach.
// CONCEPT: an instance that unmounts takes its hook state with it, and
// when it mounts again each useState initializer is evaluated
// from scratch. Park the state somewhere that never unmounts
// and the amnesia has nowhere to happen.
// HINT: a Map you mutate in place is still the very same object, and
// the same object means no re-render. Hand setOpenById a
// freshly built Map instead.
// MIRRORS: a skill tree whose HUD parks every branch's open flag in a
// quest-log store that sits outside the branches, keyed by
// skill id. Collapsing a branch unmounts the skills beneath
// it, yet the tree reopens exactly as the player left it.
// Run: pnpm koan 07-recursive-components/04-collapse-amnesia.tsx
import { useState } from "react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
afterEach(cleanup);
// ---------------------------------------------------------------------
// Tree A (given, leave it alone): every branch holds its own open flag
// in a useState all of its own.
// ---------------------------------------------------------------------
interface LocalBranchProps {
label: string;
startOpen: boolean;
children?: ReactNode;
}
function LocalBranch({ label, startOpen, children }: LocalBranchProps) {
const [open, setOpen] = useState(startOpen);
return (
<div>
<button aria-label={`toggle ${label}`} onClick={() => setOpen((o) => !o)}>
{label}
</button>
{open && <div>{children}</div>}
</div>
);
}
function LocalSkillTree() {
return (
<LocalBranch label="skills" startOpen={true}>
<LocalBranch label="combat" startOpen={false}>
<div>cleave</div>
</LocalBranch>
<LocalBranch label="stealth" startOpen={false}>
<div>backstab</div>
</LocalBranch>
</LocalBranch>
);
}
// --- TODO 1 ----------------------------------------------------------
// Three clicks on LocalSkillTree: open "combat" so cleave shows up,
// collapse "skills" so the combat branch unmounts, then open "skills"
// again. The combat branch is back on the HUD -- is cleave hanging
// under it, or did the branch come back shut? Answer 'open' or
// 'closed'.
const COMBAT_AFTER_REOPEN_LOCAL: "open" | "closed" | null = null;
// ---------------------------------------------------------------------
// Tree B (yours to finish): one Map of flags, owned by LiftedSkillTree
// -- the single component here that is never unmounted.
// ---------------------------------------------------------------------
const BRANCHES = [
{ id: "combat", label: "combat", skills: ["cleave"] },
{ id: "stealth", label: "stealth", skills: ["backstab"] },
];
function LiftedSkillTree() {
const [openById, setOpenById] = useState<Map<string, boolean>>(
() => new Map([["skills", true]]),
);
const isOpen = (id: string): boolean => openById.get(id) ?? false;
// --- TODO 2 --------------------------------------------------------
// Make a click genuinely invert this id's flag; an id nobody has
// stored yet counts as shut. setOpenById has to be handed a Map it
// has not seen before, so clone whatever came in, write the inverted
// value onto the clone, and give that back. As it stands, clicking a
// branch achieves nothing.
const toggle = (id: string): void => {
void id;
};
return (
<div>
<button aria-label="toggle skills" onClick={() => toggle("skills")}>
skills
</button>
{isOpen("skills") &&
BRANCHES.map((branch) => (
<div key={branch.id}>
<button
aria-label={`toggle ${branch.label}`}
onClick={() => toggle(branch.id)}
>
{branch.label}
</button>
{isOpen(branch.id) &&
branch.skills.map((skill) => <div key={skill}>{skill}</div>)}
</div>
))}
</div>
);
}
// --- TODO 3 ----------------------------------------------------------
// Replay TODO 1's three clicks against LiftedSkillTree: open "combat",
// collapse "skills", open "skills" again. Those branch components
// unmounted every bit as hard as Tree A's did -- so who is holding the
// flag this time round? Answer 'open' or 'closed'.
const COMBAT_AFTER_REOPEN_LIFTED: "open" | "closed" | null = null;
describe("Module 7 / Exercise 4 — collapse amnesia", () => {
it("TODO 1 — what a remounted useState brings back", () => {
render(<LocalSkillTree />);
fireEvent.click(screen.getByRole("button", { name: "toggle combat" }));
expect(screen.queryByText("cleave")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "toggle skills" }));
expect(screen.queryByText("cleave")).toBeNull();
expect(screen.queryByRole("button", { name: "toggle combat" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "toggle skills" }));
expect(
screen.queryByRole("button", { name: "toggle combat" }),
).not.toBeNull();
const combatIsNow = screen.queryByText("cleave") ? "open" : "closed";
expect(combatIsNow, "TODO 1 — fill in COMBAT_AFTER_REOPEN_LOCAL").toBe(
COMBAT_AFTER_REOPEN_LOCAL,
);
});
it("TODO 2 — toggling hands over a fresh Map, not a mutated one", () => {
render(<LiftedSkillTree />);
expect(
screen.queryByRole("button", { name: "toggle combat" }),
).not.toBeNull();
expect(screen.queryByText("cleave")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "toggle combat" }));
expect(
screen.queryByText("cleave"),
"TODO 2 — opening a branch must reveal the skills beneath it",
).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "toggle combat" }));
expect(
screen.queryByText("cleave"),
"TODO 2 — a second click must shut the branch again",
).toBeNull();
});
it("TODO 3 — lifted flags outlive the unmount", () => {
render(<LiftedSkillTree />);
fireEvent.click(screen.getByRole("button", { name: "toggle combat" }));
expect(
screen.queryByText("cleave"),
"TODO 3 — finish TODO 2 before this one can run",
).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "toggle skills" }));
expect(screen.queryByRole("button", { name: "toggle combat" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "toggle skills" }));
expect(
screen.queryByRole("button", { name: "toggle combat" }),
).not.toBeNull();
const combatIsNow = screen.queryByText("cleave") ? "open" : "closed";
expect(combatIsNow, "TODO 3 — fill in COMBAT_AFTER_REOPEN_LIFTED").toBe(
COMBAT_AFTER_REOPEN_LIFTED,
);
});
});