Conditions
A condition is a piece of “world state” the handler may want to look at when it runs. A provider component registers a getter under a condition name; when the trigger fires, the runtime calls the getter once and freezes the value for the rest of that run.
That’s a small idea with a big consequence: the trigger pulls state, the provider doesn’t push it. No subscriptions, no re-renders, no diff tracking. Triggery doesn’t have a reactive graph — it has a Map of () => T functions that get called exactly when an event needs them.
Declaring a condition
Section titled “Declaring a condition”Conditions live in the schema’s conditions map. Each entry maps a name to the value type the getter must return:
Three names, three value types. Whether they’re actually registered at fire time is a separate question — see required gate below.
Registering a getter
Section titled “Registering a getter”Providers register getters through a binding hook. useCondition takes the trigger, the condition name, a getter function, and (in React) an optional deps array with the same semantics as useMemo:
The deps array works exactly like useCallback / useMemo: when any dep changes, the runtime re-reads the latest closure. The getter itself is wrapped in a stable ref so re-renders don’t re-register the condition with the runtime.
Pull-only — what that means in practice
Section titled “Pull-only — what that means in practice”The getter runs only at fire time. Concretely:
- No subscriptions are set up between provider state and the runtime.
- Provider re-renders do not call the getter, do not invalidate anything, do not notify any other component.
- A trigger that never fires never asks for the value. Cheap.
- The same handler reading
conditions.settingstwice in one run sees the same value. The proxy caches per-run.
This is the property that decouples the trigger from the React render cycle. A toast layer, a settings panel and a chat list can all live next to a trigger without any of them rendering when a message arrives.
Reading conditions inside the handler
Section titled “Reading conditions inside the handler”Every condition the handler can mention is typed as T | undefined. The reason is honest: in the imperative createTrigger({...}) form, TypeScript can’t narrow the type based on required: [...]. So you have three safe ways to read inside an imperative handler (or use the builder API from @triggery/core/builder to narrow automatically):
Use the required field for “the handler is meaningless without this”. Use check.is for “the handler should skip when X isn’t true”. They compose.
The required gate
Section titled “The required gate”A condition listed in required: [...] must have at least one registered getter at fire time, or the handler is skipped before it runs. The inspector records a 'skipped' entry with reason 'missing-required-condition:<name>'.
This is the gate that lets you wire scenarios safely. If <UserProvider> hasn’t mounted yet, 'new-message' events arrive, get a recorded skip, and the user sees nothing. Once the provider mounts, the next event runs the handler normally. The provider can be in a different feature folder than the trigger; the trigger doesn’t import it.
In the inspector this shows up as:
— a precise signal that something is mounted late or not at all.
See Trigger anatomy → required for the full mechanics.
Adapter pattern — wrapping external stores
Section titled “Adapter pattern — wrapping external stores”A useState value is a trivial getter. The same pattern works for any external store — Zustand, Redux, Jotai, MobX, Signals, TanStack Query. The adapter packages do this in ~30 lines: they read from the store synchronously and hand the value back through useCondition.
The Zustand store re-renders the provider when its slice changes — but that’s the provider’s business, and Triggery doesn’t care. The runtime still only calls () => s.id at fire time, and the cached useStore selector hands back the latest value.
The same shape works for @triggery/redux, @triggery/jotai, @triggery/query, and any store you author yourself — see Adapters.
Last-write-wins ownership
Section titled “Last-write-wins ownership”What if two providers register the same condition name on the same trigger? Each (trigger, name) pair holds one slot: the most recent registration overwrites the previous. When it unmounts, the slot is cleared (the previous registration is not restored — v0.10 removed the stack-based fallback).
Two intentional consequences:
- Tests and overrides are simple. A test writes the test value second; it wins.
- StrictMode is safe. React 18 StrictMode mounts → cleanup → mounts in development. The first mount’s cleanup clears the slot before the second mount writes — no spurious collisions.
In DEV, the runtime emits a one-time console.warn per (triggerId, conditionName) when a second live registration arrives:
This is a heads-up that probably something is wrong. If you genuinely want last-write-wins (overrides, tests), it’s fine to ignore. See Ownership for the full discussion and patterns for composing values when you have multiple sources of truth.
Scoped conditions
Section titled “Scoped conditions”Conditions register globally by default. Wrap a subtree with <TriggerScope id="..."> and conditions registered inside become visible only to triggers whose scope matches:
Two <ChatRoom> instances in two scopes operate on two independent settings conditions. The scope key is a string — you can match by tenant, by route, by feature flag. See Scopes.
Common patterns
Section titled “Common patterns”Conditions are nouns, not actions. settings, currentUserId, cartTotal — not getSettings or loadCart. The verb is implicit in “read this at fire time”.
One condition per concept. Don’t bundle unrelated values into one appState condition just because they happen to be in the same Zustand store. The runtime cares about whether the value is registered; mixing concerns makes the required list lie.
Keep getters cheap. They run at fire time, and a misbehaving getter throws inside the dispatch loop. Don’t fetch from a getter. Don’t synchronously decompress a 3 MB blob. If the value is expensive, memoize at the provider level and have the getter return the cached result.
Use check.is instead of nested if. It’s shorter, it handles the undefined case for you, and the inspector records a snapshotKeys entry only when the condition was actually consulted. Cleaner traces.