@umpire/zustand
@umpire/zustand is the Zustand integration for Umpire. Zustand passes both current and previous state to subscribers out of the box, which means transition-aware foul detection just works — no extra bookkeeping required.
The demo below is a print-dialog simulator built with a vanilla Zustand store and fromStore().
Install
Section titled “Install”yarn add @umpire/core @umpire/zustand zustandfromStore()
Section titled “fromStore()”function fromStore< S, F extends Record<string, FieldDef>, C extends Record<string, unknown> = Record<string, unknown>,>( ump: Umpire<F, C>, store: { getState(): S subscribe(listener: (state: S, prevState: S) => void): () => void }, options: { select: (state: S) => InputValues conditions?: (state: S) => C },): UmpireStore<F>Return Surface
Section titled “Return Surface”interface UmpireStore<F extends Record<string, FieldDef>> { field(name: keyof F & string): FieldStatus get fouls(): Foul<F>[] getAvailability(): AvailabilityMap<F> subscribe(listener: (availability: AvailabilityMap<F>) => void): () => void destroy(): void}Mapping Your Store with select()
Section titled “Mapping Your Store with select()”select maps your Zustand state to the flat { [fieldName]: value } shape Umpire expects. If your fields live in nested objects or separate slices, this is where you pull them together:
// Store: { profile: { email, displayName }, team: { size, domain }, billing: { plan } }fromStore(accountUmp, store, { select: (state) => ({ email: state.profile.email, displayName: state.profile.displayName, teamSize: state.team.size, teamDomain: state.team.domain, }), conditions: (state) => ({ plan: state.billing.plan, }),})select runs once per store update, not per render. The result is cached — field() and getAvailability() read from it during render.
For the full breakdown of patterns (pass-through, nested, split-slice, select vs conditions), see Selection.
Why Zustand Fits Well
Section titled “Why Zustand Fits Well”Zustand’s subscribe() passes both current and previous state to every listener. Umpire uses the previous snapshot to detect transitions — which fields just became stale, which values should be cleared. Most stores require a shim to provide this; Zustand doesn’t.
Example
Section titled “Example”import { createStore } from 'zustand/vanilla'import { disables, enabledWhen, requires, umpire } from '@umpire/core'import { fromStore } from '@umpire/zustand'
const fields = { printer: { required: true, isEmpty: (v: unknown) => !v }, copies: { required: true, isEmpty: (v: unknown) => !v }, colorMode: {}, duplex: {}, bannerMode: {}, paperSize: {}, collate: {},}
const printerUmp = umpire({ fields, rules: [ // colorMode and duplex only available on the color laser enabledWhen('colorMode', (v) => v.printer === 'colorLaser', { reason: 'This printer has a fixed color mode', }), enabledWhen('duplex', (v) => v.printer === 'colorLaser', { reason: 'Only the color laser supports duplex', }), // bannerMode only on dot-matrix enabledWhen('bannerMode', (v) => v.printer === 'dotMatrix', { reason: 'Banner mode is only available on the dot-matrix', }), // bannerMode disables paperSize — continuous feed has no page boundaries disables('bannerMode', ['paperSize'], { reason: 'Banner mode uses continuous feed', }), // collate requires more than one copy requires('collate', (v) => Number(v.copies) > 1, { reason: 'Collation requires multiple copies', }), ],})
const store = createStore(() => ({ printer: 'dotMatrix', copies: '1', colorMode: 'bw', duplex: false, bannerMode: false, paperSize: 'letter', collate: false,}))
const umpStore = fromStore(printerUmp, store, { select: (state) => ({ printer: state.printer, copies: state.copies, colorMode: state.colorMode, duplex: state.duplex || undefined, bannerMode: state.bannerMode || undefined, paperSize: state.paperSize, collate: state.collate || undefined, }),})
umpStore.subscribe((availability) => { console.log(availability.colorMode.enabled) // false — dot-matrix has fixed color mode console.log(availability.colorMode.reason) // 'This printer has a fixed color mode' console.log(umpStore.fouls) // reset recommendations on printer switch})
// Switch to color laser — colorMode and duplex become availablestore.setState({ printer: 'colorLaser' })
// Enable banner mode — paperSize becomes disabledstore.setState({ printer: 'dotMatrix', bannerMode: true })
umpStore.destroy()Using with React
Section titled “Using with React”The adapter itself doesn’t depend on React. If your app uses Zustand with React, bridge the UmpireStore using useStore from zustand:
import { useStore } from 'zustand'
// Use the same vanilla store + fromStore setup from above.// Then subscribe React components with selectors:function PaperSizeControl() { const paperSize = useStore(store, (s) => s.paperSize) const availability = umpStore.field('paperSize')
return ( <select value={paperSize} disabled={!availability.enabled} onChange={(e) => store.setState({ paperSize: e.target.value })} > <option value="letter">Letter</option> <option value="legal">Legal</option> <option value="4x6">4 x 6</option> </select> )}Cleanup
Section titled “Cleanup”Call destroy() when you no longer need the adapter so it unsubscribes from the underlying Zustand store.