@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.
Event Recurrence
{ recurring: false }ââââ{
"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.
Why signals instead of useUmpire?
Section titled âWhy signals instead of useUmpire?â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.
Install
Section titled âInstallâyarn add @umpire/core @umpire/signalsThen add the peer dependency for your signal library. See the adapter pages for specifics.
SignalProtocol
Section titled âSignalProtocolâ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.
reactiveUmp()
Section titled âreactiveUmp()â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>Return Surface
Section titled âReturn Surfaceâ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}Owned Signals
Section titled âOwned Signalsâ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').enabledreactive.set('startTime', '09:00')reactive.update({ endTime: '17:00', repeatEvery: 30 })reactive.valuesExternal Signals
Section titled âExternal Signalsâ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 Signals
Section titled âConditions SignalsâConditions can also be reactive.
const reactive = reactiveUmp(loginUmp, adapter, { conditions: { captchaToken: captchaTokenSignal, },})Changing captchaTokenSignal recomputes availability just like changing a field signal.
Fouls Tracking
Section titled âFouls Trackingâ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 access
Section titled âPer-field accessâ// Per-field â use in FieldControl componentsconst foul = reactive.foul('repeatUnit')if (foul) { // foul.reason, foul.suggestedValue}
// Full array â use for banner renderingconst allFouls = reactive.foulsNo effect() fallback
Section titled âNo effect() fallbackâIf the protocol does not provide effect():
- creation logs a warning
- field availability still works
- reading
foulsor callingfoul()throws
Proxy-Based Fine-Grained Tracking
Section titled âProxy-Based Fine-Grained TrackingâPredicates receive a values object, but internally that object is a Proxy that forwards property access to field signals.
That means:
values.startTimeonly tracksstartTime- 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.
Adapters
Section titled âAdaptersâPre-wired adapters are available as sub-path exports:
| Import | Library |
|---|---|
@umpire/signals/preact | @preact/signals-core |
@umpire/signals/alien | alien-signals |
@umpire/signals/vue | vue |
@umpire/signals/solid | solid-js |
@umpire/signals/tc39 | signal-polyfill (TC39 proposal) |