js-dojo

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.tsx

Source

// 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}`);
  });
});

Solution