Skip to content

@umpire/signals

@umpire/signals adapts the pure core to any signal library that matches the SignalProtocol interface. The demo below uses Preact signals — components read signal-backed values directly during render and Preact auto-subscribes to changes. No hooks, no effects, no manual bridging.

Live config

Event Recurrence

reactiveUmp()
Conditions{ recurring: false }
Start Timerequired
enabled
—
End Timerequired
enabled
—
Repeat Every
disabled
—
only applies to recurring events
Repeat Unit
disabled
—
only applies to recurring events
signal state@preact/signals
{
  "conditions": {
    "recurring": false
  },
  "values": {},
  "fouls": []
}

Each field is a self-contained component. When the recurring condition signal changes, only the fields whose availability actually moved re-render — the rest stay frozen.

import { signal } from '@preact/signals'
import { reactiveUmp } from '@umpire/signals'
// Preact components auto-subscribe to signal reads during render.
// No useEffect, no useState, no queueMicrotask.
function FieldControl({ field, reactive }) {
const availability = reactive.field(field)
const value = reactive.values[field]
return (
<div class={availability.enabled ? '' : 'disabled'}>
<span>{availability.enabled ? 'enabled' : 'disabled'}</span>
<span>{availability.reason}</span>
<input
value={value}
disabled={!availability.enabled}
onInput={(e) => reactive.set(field, e.currentTarget.value)}
/>
</div>
)
}

When reactive.field(field).enabled changes, only this component re-renders. The parent, the sibling fields, and the rest of the page don’t move.

useUmpire derives everything on every render — check() runs, the full availability map rebuilds, React reconciles the whole tree. For most forms that’s fine.

@umpire/signals gives you per-field subscriptions. Each consumer only recomputes when its own dependencies move. If you have dozens of interdependent fields and want surgical updates, that’s the difference.

If you’d collapse all signals into one blob and re-render everything anyway, just use useUmpire — it’s simpler and does exactly that.


Terminal window
yarn add @umpire/core @umpire/signals

Then add the peer dependency for your signal library. See the adapter pages for specifics.

interface SignalProtocol {
signal<T>(initial: T): { get(): T; set(value: T): void }
computed<T>(fn: () => T): { get(): T }
effect?(fn: () => void | (() => void)): () => void
batch?(fn: () => void): void
}

effect and batch are optional at the protocol level, but effect is required if you want fouls.

import type { SignalProtocol } from '@umpire/signals'
import { reactiveUmp } from '@umpire/signals'
function reactiveUmp<
F extends Record<string, FieldDef>,
C extends Record<string, unknown> = Record<string, unknown>,
>(
ump: Umpire<F, C>,
adapter: SignalProtocol,
options?: {
signals?: Partial<Record<keyof F & string, { get(): unknown; set(value: unknown): void }>>
conditions?: Record<string, { get(): unknown }>
},
): ReactiveUmpire<F>
type ReactiveField = {
readonly enabled: boolean
readonly required: boolean
readonly reason: string | null
readonly reasons: string[]
}
interface ReactiveUmpire<F extends Record<string, FieldDef>> {
field(name: keyof F & string): ReactiveField
foul(name: keyof F & string): Foul<F> | undefined
set(name: keyof F & string, value: unknown): void
update(partial: Partial<Record<keyof F & string, unknown>>): void
readonly values: Record<keyof F & string, unknown>
readonly fouls: Foul<F>[]
dispose(): void
}

If you do not pass options.signals, reactiveUmp() creates one writable signal per field using ump.init().

const reactive = reactiveUmp(recurrenceUmp, adapter)
reactive.field('startTime').enabled
reactive.set('startTime', '09:00')
reactive.update({ endTime: '17:00', repeatEvery: 30 })
reactive.values

If your values already live in signals, pass them in. Unspecified fields still get owned signals.

const reactive = reactiveUmp(recurrenceUmp, adapter, {
signals: {
startTime: startTimeSignal,
endTime: endTimeSignal,
repeatEvery: repeatEverySignal,
},
})

Conditions can also be reactive.

const reactive = reactiveUmp(loginUmp, adapter, {
conditions: {
captchaToken: captchaTokenSignal,
},
})

Changing captchaTokenSignal recomputes availability just like changing a field signal.

fouls depends on effect().

The adapter uses an effect to advance an internal “before” snapshot whenever field or conditions signals change, then computes ump.play(before, after) from that transition.

// Per-field — use in FieldControl components
const foul = reactive.foul('repeatUnit')
if (foul) {
// foul.reason, foul.suggestedValue
}
// Full array — use for banner rendering
const allFouls = reactive.fouls

If the protocol does not provide effect():

  • creation logs a warning
  • field availability still works
  • reading fouls or calling foul() throws

Predicates receive a values object, but internally that object is a Proxy that forwards property access to field signals.

That means:

  • values.startTime only tracks startTime
  • destructuring specific fields still tracks only those fields
  • spreading or enumerating all keys defeats fine-grained tracking

Avoid patterns like:

const snapshot = { ...values }
Object.keys(values)
JSON.stringify(values)

Those patterns cause all field signals to be read.

Pre-wired adapters are available as sub-path exports:

ImportLibrary
@umpire/signals/preact@preact/signals-core
@umpire/signals/alienalien-signals
@umpire/signals/vuevue
@umpire/signals/solidsolid-js
@umpire/signals/tc39signal-polyfill (TC39 proposal)