5 build the expansion store
GOAL
assemble the whole module into one skill tree store - a sparse map of overrides plus useSyncExternalStore - so expanding one branch skill redraws that branch and nothing else.
CONCEPT
useSyncExternalStore(subscribe, getSnapshot) redraws a component only when getSnapshot() comes back unequal to last time. Ping every subscriber; only the ones whose answer changed are redrawn.
HINT
every piece is a drill you have already done: add and remove a listener, read the state at call time, ping from a frozen copy of the listener set. The Map holds overrides ONLY - an id with no entry falls back to the default the caller worked out.
MIRRORS
the HUD's skill tree, where a hundred branch skills share one store. The store remembers only the branches the player actually touched; every other branch falls back to the tree's own default, and expanding one leaves the other ninety-nine alone.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 09-closures-and-stores/05-build-the-expansion-store.tsxSource
// DOJO · Module 9 / Exercise 5 — build the expansion store
// GOAL: assemble the whole module into one skill tree store - a sparse
// map of overrides plus useSyncExternalStore - so expanding one
// branch skill redraws that branch and nothing else.
// CONCEPT: useSyncExternalStore(subscribe, getSnapshot) redraws a
// component only when getSnapshot() comes back unequal to last
// time. Ping every subscriber; only the ones whose answer
// changed are redrawn.
// HINT: every piece is a drill you have already done: add and remove a
// listener, read the state at call time, ping from a frozen copy
// of the listener set. The Map holds overrides ONLY - an id with
// no entry falls back to the default the caller worked out.
// MIRRORS: the HUD's skill tree, where a hundred branch skills share
// one store. The store remembers only the branches the player
// actually touched; every other branch falls back to the tree's
// own default, and expanding one leaves the other ninety-nine
// alone.
// Run: pnpm koan 09-closures-and-stores/05-build-the-expansion-store.tsx
import { memo, useSyncExternalStore } from 'react';
import { afterEach, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
// HUD redraw tally: every branch skill bumps its own id at the top of
// its body, so a test can ask how often the HUD drew that branch.
const redraws = new Map<string, number>();
function drew(id: string): void {
redraws.set(id, (redraws.get(id) ?? 0) + 1);
}
function redrawsOf(id: string): number {
return redraws.get(id) ?? 0;
}
afterEach(() => {
cleanup();
redraws.clear();
});
type Listener = () => void;
class SkillTreeStore {
// Sparse: holds ONLY the branches the player actually toggled.
// Public so the tests can look inside; a shipped store keeps it
// private.
readonly overrides = new Map<string, boolean>();
// Public for the same reason.
readonly listeners = new Set<Listener>();
// -- TODO 1 ---------------------------------------------------------
// subscribe: put the listener into the Set, and hand back a function
// that takes it out again. It is an arrow-function class field on
// purpose: `this` stays pinned to the store and the function's own
// identity never changes, so useSyncExternalStore can be handed
// `store.subscribe` itself without resubscribing on every redraw.
subscribe = (listener: Listener): (() => void) => {
void listener; // BROKEN: registers nobody, returns a decoy
return () => {};
};
// -- TODO 2 ---------------------------------------------------------
// isOpen: a branch with an entry in `overrides` uses that entry; a
// branch with no entry uses the default the caller worked out
// (`fallback`). The read happens at call time, every time, so
// nothing in here can go stale.
isOpen(id: string, fallback: boolean): boolean {
void id; // BROKEN: never looks at the overrides
return fallback;
}
// -- TODO 3 ---------------------------------------------------------
// toggle: write the OPPOSITE of the current answer into `overrides`
// (current answer = this.isOpen(id, fallback), read at call time),
// then ping every listener by looping over a frozen copy of the Set.
toggle(id: string, fallback: boolean): void {
void id; // BROKEN: writes nothing, pings nobody
void fallback;
}
}
interface BranchProps {
id: string;
store: SkillTreeStore;
}
// Given: three branch skills. Each one is memo()'d and subscribes for
// itself, and the store object's identity never changes, so a branch
// redraws only when its own snapshot - store.isOpen(id, false) - comes
// back unequal to the one before.
const BranchSkill = memo(function BranchSkill({ id, store }: BranchProps) {
drew(id);
const open = useSyncExternalStore(store.subscribe, () =>
store.isOpen(id, false),
);
return (
<div>
<button onClick={() => store.toggle(id, false)}>{id}</button>
<span data-testid={`state-${id}`}>{open ? 'open' : 'closed'}</span>
</div>
);
});
function SkillTree({ store }: { store: SkillTreeStore }) {
return (
<div>
<BranchSkill id="blade" store={store} />
<BranchSkill id="ward" store={store} />
<BranchSkill id="hex" store={store} />
</div>
);
}
it('TODO 1 — subscribe registers, and its return value unregisters', () => {
const store = new SkillTreeStore();
const unsubscribe = store.subscribe(() => {});
expect(store.listeners.size).toBe(1);
expect(typeof unsubscribe).toBe('function');
unsubscribe();
expect(store.listeners.size).toBe(0);
});
it('TODO 2 — isOpen: an override wins, an untouched branch falls back', () => {
const store = new SkillTreeStore();
expect(store.isOpen('ward', true)).toBe(true);
expect(store.isOpen('ward', false)).toBe(false);
store.overrides.set('ward', true);
expect(store.isOpen('ward', false)).toBe(true);
store.overrides.set('ward', false);
expect(store.isOpen('ward', true)).toBe(false);
});
it('TODO 3 — expanding one branch redraws only that branch', () => {
const store = new SkillTreeStore();
// The store on its own first: toggle flips through the call-time
// answer, and every listener gets pinged.
let pings = 0;
const unsubscribe = store.subscribe(() => {
pings += 1;
});
store.toggle('ward', false);
expect(store.isOpen('ward', false)).toBe(true);
expect(pings).toBe(1);
store.toggle('ward', false);
expect(store.isOpen('ward', false)).toBe(false);
expect(pings).toBe(2);
// sparse: one entry for the one touched branch, after two toggles
expect(store.overrides.size).toBe(1);
store.overrides.clear();
unsubscribe();
// Now the same store wired through the HUD.
render(<SkillTree store={store} />);
expect(screen.getByTestId('state-ward').textContent).toBe('closed');
fireEvent.click(screen.getByRole('button', { name: 'ward' }));
expect(screen.getByTestId('state-ward').textContent).toBe('open');
expect(redrawsOf('ward')).toBe(2); // mount, then the toggle
expect(redrawsOf('blade')).toBe(1); // pinged, same answer, not redrawn
expect(redrawsOf('hex')).toBe(1);
// blade and hex never got an entry of their own
expect([...store.overrides.keys()]).toEqual(['ward']);
});