3 keys are identity
GOAL
make a skill's pinned flag travel with the skill through a reorder, by choosing what the key names: the slot or the skill.
CONCEPT
on a redraw React matches the new list elements to the instances it already has by key. key={index} names the SLOT, so after a reorder slot 0 still exists and its old instance - state included - is simply handed the NEXT skill's data.
HINT
which field of a skill stays the same wherever the skill moves?
MIRRORS
the pinned-skills strip on the player's HUD. Every slot is keyed by the skill's own id, never by the slot it happens to sit in, so re-ordering the strip carries each pin along with its skill instead of leaving the pin behind on the slot.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 07-recursive-components/03-keys-are-identity.tsxSource
// DOJO · Module 7 / Exercise 3 — keys are identity
// GOAL: make a skill's pinned flag travel with the skill through a
// reorder, by choosing what the key names: the slot or the skill.
// CONCEPT: on a redraw React matches the new list elements to the
// instances it already has by key. key={index} names the SLOT, so
// after a reorder slot 0 still exists and its old instance -
// state included - is simply handed the NEXT skill's data.
// HINT: which field of a skill stays the same wherever the skill moves?
// MIRRORS: the pinned-skills strip on the player's HUD. Every slot is
// keyed by the skill's own id, never by the slot it happens to
// sit in, so re-ordering the strip carries each pin along with
// its skill instead of leaving the pin behind on the slot.
// Run: pnpm koan 07-recursive-components/03-keys-are-identity.tsx
import { useState } 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);
interface Skill {
id: string;
name: string;
}
// Three leaf skills hanging off one branch of the skill tree.
const START_SKILLS: Skill[] = [
{ id: "cleave", name: "cleave" },
{ id: "volley", name: "volley" },
{ id: "mend", name: "mend" },
];
// One leaf skill. Whether the player pinned it to the HUD is local
// state.
function SkillSlot({ skill }: { skill: Skill }) {
const [pinned, setPinned] = useState(false);
return (
<label>
<input
type="checkbox"
aria-label={`pin ${skill.name}`}
checked={pinned}
onChange={() => setPinned((p) => !p)}
/>
{skill.name}
</label>
);
}
function SkillBranch() {
const [skills, setSkills] = useState(START_SKILLS);
const cycle = (): void => setSkills(([first, ...rest]) => [...rest, first]);
// --- TODO 1 --------------------------------------------------------
// These slots are keyed by array index. After "send first to bottom",
// slot 0 still exists, so its instance - the pin included - is handed
// the NEXT skill's data. Key the slots by something that moves with
// the skill instead.
const slots = skills.map((skill, index) => (
<SkillSlot key={index} skill={skill} />
));
return (
<div>
<button onClick={cycle}>send first to bottom</button>
{slots}
</div>
);
}
// --- TODO 2 ----------------------------------------------------------
// With your fix in place: pin cleave, then click "send first to bottom"
// once. Exactly one box is still pinned. Fill in the name of the skill
// sitting beside that pinned box.
const PINNED_SKILL_AFTER_CYCLE: string | null = null;
describe("Module 7 / Exercise 3 — keys are identity", () => {
it("TODO 1 - the pin follows the skill, not the slot", () => {
render(<SkillBranch />);
fireEvent.click(screen.getByRole("checkbox", { name: "pin cleave" }));
expect(
(screen.getByRole("checkbox", { name: "pin cleave" }) as HTMLInputElement)
.checked,
"TODO 1 setup: pinning cleave should pin it",
).toBe(true);
fireEvent.click(screen.getByText("send first to bottom"));
expect(
(screen.getByRole("checkbox", { name: "pin cleave" }) as HTMLInputElement)
.checked,
"TODO 1: after the reorder cleave must still carry its own pin",
).toBe(true);
expect(
(screen.getByRole("checkbox", { name: "pin volley" }) as HTMLInputElement)
.checked,
"TODO 1: volley moved into slot 0 and must NOT inherit its pin",
).toBe(false);
});
it("TODO 2 - predict where the pin lands", () => {
render(<SkillBranch />);
fireEvent.click(screen.getByRole("checkbox", { name: "pin cleave" }));
fireEvent.click(screen.getByText("send first to bottom"));
const pinnedBoxes = screen
.getAllByRole("checkbox")
.filter((box) => (box as HTMLInputElement).checked);
expect(pinnedBoxes.length, "TODO 2: exactly one box stays pinned").toBe(1);
expect(
pinnedBoxes[0].getAttribute("aria-label"),
"TODO 2: name the skill the surviving pin belongs to",
).toBe(`pin ${PINNED_SKILL_AFTER_CYCLE}`);
});
});