js-dojo

1 component vs instance

GOAL

give every skill in the pause-menu skill tree its own expanded/collapsed flag, then work out how many of those flags a wide-open tree is really carrying.

CONCEPT

a component is a rubber stamp. Every place it renders is one stamping - an instance - and each stamping gets its own private state slots. A variable declared at module scope is the opposite: one value, shared by every stamping alive.

HINT

nothing is wrong with HOW the flag is flipped. Ask WHERE it lives, and who else can see it from there.

MIRRORS

the skill tree behind the pause button. One branch component is stamped once per skill, and each stamping remembers on its own whether its sub-skills are showing, which is why folding up "melee" leaves "ranged" wide open on the HUD.

Run

This koan renders React, so it needs a one-time setup.

pnpm koan 07-recursive-components/01-component-vs-instance.tsx

Source

// DOJO · Module 7 / Exercise 1 — component vs instance
// GOAL: give every skill in the pause-menu skill tree its own
//       expanded/collapsed flag, then work out how many of those
//       flags a wide-open tree is really carrying.
// CONCEPT: a component is a rubber stamp. Every place it renders is
//       one stamping - an instance - and each stamping gets its own
//       private state slots. A variable declared at module scope is
//       the opposite: one value, shared by every stamping alive.
// HINT: nothing is wrong with HOW the flag is flipped. Ask WHERE it
//       lives, and who else can see it from there.
// MIRRORS: the skill tree behind the pause button. One branch
//       component is stamped once per skill, and each stamping
//       remembers on its own whether its sub-skills are showing,
//       which is why folding up "melee" leaves "ranged" wide open on
//       the HUD.
// Run: pnpm koan 07-recursive-components/01-component-vs-instance.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 SkillNode {
  name: string;
  children: SkillNode[];
}

const SKILL_TREE: SkillNode = {
  name: "combat",
  children: [
    { name: "melee", children: [{ name: "cleave", children: [] }] },
    { name: "ranged", children: [{ name: "volley", children: [] }] },
  ],
};

interface SkillBranchProps {
  skill: SkillNode;
  requestRedraw: () => void;
}

// --- TODO 1 ----------------------------------------------------------
// This flag sits at module scope: ONE variable that every SkillBranch
// stamping reads and writes. Fold up any single skill and the whole
// tree folds with it. Move the flag inside SkillBranch so that each
// stamping owns a copy nobody else can touch.
// (The requestRedraw wiring in Hud is sound - that is not the bug.)
let isExpanded = true;

function SkillBranch({ skill, requestRedraw }: SkillBranchProps) {
  return (
    <div>
      <button
        aria-label={`fold ${skill.name}`}
        onClick={() => {
          isExpanded = !isExpanded;
          requestRedraw();
        }}
      >
        {skill.name}
      </button>
      {isExpanded &&
        skill.children.map((child) => (
          <SkillBranch
            key={child.name}
            skill={child}
            requestRedraw={requestRedraw}
          />
        ))}
    </div>
  );
}

function Hud() {
  const [, setFrame] = useState(0);
  const redraw = (): void => setFrame((n) => n + 1);
  return <SkillBranch skill={SKILL_TREE} requestRedraw={redraw} />;
}

// --- TODO 2 ----------------------------------------------------------
// SKILL_TREE mounts one SkillBranch stamping per skill. Once TODO 1 is
// fixed, every stamping owns exactly one expanded flag. Walk the tree
// above, count its skills, and write down how many independent flags
// an untouched, wide-open tree is carrying.
const EXPANDED_FLAG_COUNT: number | null = null;

describe("Module 7 / Exercise 1 — component vs instance", () => {
  it("TODO 1 — folding one skill leaves its siblings open", () => {
    render(<Hud />);
    expect(screen.queryByText("cleave"), "TODO 1 setup").not.toBeNull();
    expect(screen.queryByText("volley"), "TODO 1 setup").not.toBeNull();

    fireEvent.click(screen.getByRole("button", { name: "fold melee" }));

    expect(
      screen.queryByText("cleave"),
      "TODO 1 — folding melee should hide its own sub-skill",
    ).toBeNull();
    expect(
      screen.queryByText("volley"),
      "TODO 1 — folding melee must NOT fold the ranged branch too",
    ).not.toBeNull();
  });

  it("TODO 2 — one component, many stampings, one flag each", () => {
    // SKILL_TREE mounts one stamping per skill, and after TODO 1 each
    // of those holds an expanded flag of its own. Your number has to
    // agree with the tree itself.
    const countSkills = (node: SkillNode): number =>
      1 + node.children.reduce((sum, child) => sum + countSkills(child), 0);

    expect(
      EXPANDED_FLAG_COUNT,
      "TODO 2 — count the skills SKILL_TREE mounts",
    ).toBe(countSkills(SKILL_TREE));
  });
});

Solution