PC Builder Wizard
A wizard-style PC configurator where the interesting part is not the filtering. The interesting part is what happens after the user has already made a coherent build, then jumps back to Step 1 and changes the platform underneath it.
The Cascade
Section titled âThe Cascadeâ- Pick an Intel CPU, then choose a matching motherboard and RAM kit.
- Add a case so the graph has something downstream of the motherboard.
- Go back to Platform and switch from Intel to AMD.
- The motherboard is now stale, so
play()calls a foul there first. - RAM and Case fall with it because they depend on an active motherboard, not just a non-empty string in state.
That last part is the point of the demo. The user only touched one field. Umpire still finds the downstream stale state and turns it into reset recommendations.
Two Umpires
Section titled âTwo UmpiresâThe form umpire owns actual build availability and value coherence:
import { fairWhen, requires, umpire } from '@umpire/core'
const pcUmp = umpire({ fields: { cpu: { required: true, isEmpty: (value) => !value }, motherboard: { required: true, isEmpty: (value) => !value }, ram: { required: true, isEmpty: (value) => !value }, gpu: { isEmpty: (value) => !value }, storage: { isEmpty: (value) => !value }, caseSize: { required: true, isEmpty: (value) => !value }, }, rules: [ requires('motherboard', 'cpu', { reason: 'Pick a CPU first', }), fairWhen('motherboard', (value, values) => socketForMotherboard(value) === socketForCpu(values.cpu), { reason: 'Selected motherboard no longer matches the CPU socket', }),
requires('ram', 'motherboard', { reason: 'Memory depends on an active motherboard selection', }), fairWhen('ram', (value, values) => ramTypeForKit(value) === ramTypeForMotherboard(values.motherboard), { reason: 'Selected memory no longer matches the motherboard RAM type', }),
requires('caseSize', 'motherboard', { reason: 'Pick a valid motherboard first to determine form factor', }), fairWhen('caseSize', (value, values) => caseFitsMotherboard(value, values.motherboard), { reason: 'Selected case no longer fits the motherboard form factor', }), ],})The second umpire is now just a hint umpire. It does not control the form. It decides when to surface teaching hints:
type HintInput = { cpuBrand?: 'intel' | 'amd' hasRamSelection: boolean sawTransitiveCascade: boolean sawAppliedResets: boolean}
const hintReads = createReads<HintInput>({ canPromptSwitchCpu: ({ input }) => input.hasRamSelection && input.cpuBrand === 'intel', canExplainTransitive: ({ input }) => input.sawTransitiveCascade, canCelebrateComplete: ({ input }) => input.sawTransitiveCascade && input.sawAppliedResets,})
const hintUmp = umpire({ fields: { promptSwitchCpu: {}, explainTransitive: {}, celebrateComplete: {}, }, rules: [ enabledWhenRead('promptSwitchCpu', 'canPromptSwitchCpu', hintReads, { inputType: ReadInputType.CONDITIONS, reason: 'Complete steps 1-3 with Intel first', }), enabledWhenRead('explainTransitive', 'canExplainTransitive', hintReads, { inputType: ReadInputType.CONDITIONS, reason: 'Trigger the transitive cascade first', }), enabledWhenRead('celebrateComplete', 'canCelebrateComplete', hintReads, { inputType: ReadInputType.CONDITIONS, reason: 'Apply the suggested resets first', }), ],})The actual coach layer is now separate from those visible hints. It composes the umpire plus named domain reads:
const pcCoach = createCoach({ ump: pcUmp, reads: pcBuildReads, getReadInput: (snapshot) => snapshot.values,})
const coaching = pcCoach.inspect({ values }, { before: lastTransition?.before,})
const buildReads = coaching.reads.valuesconst scorecard = coaching.scorecardThose same reads can also feed straight back into the form umpire with baked-in predicates:
fairWhenRead('motherboard', 'motherboardFair', pcBuildReads, { reason: 'Selected motherboard no longer matches the CPU socket',})The only stateful part left is marker memory: remembering milestone booleans like âthe transitive cascade has happened at least once.â The active hint itself can stay purely derived.
The demo now splits that into:
createCoach(...)for the domain-aware layer that consumes umpire plus readscreateReads(...)for named domain reads shared by rules, scorecard, and UIfairWhenRead(...)/enabledWhenRead(...)as the read-backed adapters from read table to rulescorecard(...)inside the coach layer for live field and transition facts- local marker memory for milestone history
- a derived
activeHintchosen from the hint umpire result
The remaining marker state is tiny:
const [hintMarkers, setHintMarkers] = useState({ sawTransitiveCascade: false, sawAppliedResets: false,})And the active hint stays derived while the hint input is powered by coach output:
const hintInput = { cpuBrand: buildReads.selections.cpu?.brand, hasRamSelection: scorecard.fields.ram.satisfied, sawTransitiveCascade: hintMarkers.sawTransitiveCascade || hasLiveTransitiveCascade, sawAppliedResets: hintMarkers.sawAppliedResets,}
const hintCheck = hintUmp.check(hintUmp.init(), hintInput)const activeHint = resolveActiveHint(hintCheck)
// inside a transition handlersetHintMarkers((current) => rememberHintMarkers(current, { sawTransitiveCascade: hasTransitiveCascade(nextFouls),}))That feels like a better extraction seam than the earlier runtime-heavy version. The reusable part is not rendering. It is âcompose umpire state with domain reads, then let hints consume that richer scorecard.â Marker memory stays local and tiny, while hint selection itself remains derived instead of syncing through an effect.
The Compatibility Layer
Section titled âThe Compatibility LayerâThe lookup tables still live in the UI:
- Motherboards are filtered by the selected CPU socket.
- RAM kits are filtered by the selected motherboard RAM type.
- Cases are filtered by the selected motherboard form factor.
That filtering logic is still not what Umpire is responsible for. The component keeps the product catalog in userland, but it now declares named build reads once and reuses them from fairWhenRead(), the scorecard, and render. The read table is starting to look less like a plain helper bag and more like a small constructed layer with its own baked-in helpers. Umpire owns the rule result and the cascade. The app still owns the catalog.
What Umpire Doesnât Do
Section titled âWhat Umpire Doesnât Doâ- It does not own the componentâs product catalog. CPU, motherboard, RAM, and case data are plain app lookups.
- It does not compute filtered option lists. That is ordinary UI logic.
- It does not calculate the PSU recommendation. That label is derived directly from CPU tier plus GPU tier in render.
- It does not clear fields automatically. The demo keeps stale values in state until
play()recommends a reset and the user applies it.