Skip to content

@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().

Terminal window
yarn add @umpire/core @umpire/zustand zustand
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>
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
}

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.

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.

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 available
store.setState({ printer: 'colorLaser' })
// Enable banner mode — paperSize becomes disabled
store.setState({ printer: 'dotMatrix', bannerMode: true })
umpStore.destroy()

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>
)
}

Call destroy() when you no longer need the adapter so it unsubscribes from the underlying Zustand store.