Overview
Headless React bindings and composition helpers for Conditions.
@saas-js/conditions-react owns React lifecycle, scoped contexts,
subscriptions, incomplete UI drafts, and filter-chip interaction
orchestration. The conditions definition remains the only source of field,
operator, validation, evaluation, and serialization behavior.
There are no styles and no component-library dependencies. React is a peer
dependency and @saas-js/conditions is external to the bundle.
npm install @saas-js/conditions @saas-js/conditions-reactReady-made adapters:
Architecture
Each useConditions call creates or adopts one committed Conditions store and
one draft controller:
- The committed store always contains a valid expression query.
- The draft controller can contain a missing field, operator, or value.
commitDraftvalidates through the definition's public Standard Schema API and applies one atomic store action.- Contexts contain the stable controller and node IDs, never state snapshots.
- Hooks subscribe with selectors through
useSyncExternalStorewith selector memoization. A condition component can select one condition without rerendering when a sibling changes. An optionalisEqualfunction keeps derived selections (arrays, objects) referentially stable. - Bound components (
Root, scopes,Subscribe,ValueEditor) are created once per hook instance, so component identities stay stable for the life of the instance.
The React package does not evaluate, serialize, or independently validate a
query. conditions.filter(subjects) / conditions.useFilter(subjects)
delegate to the definition. Use conditions.definition or conditions.store
for everything else.
Create a component composition
Bind the definition when creating the hook. Every hook, selector, and draft action is fully typed without generic annotations.
import { z } from 'zod'
import { defineConditions } from '@saas-js/conditions'
import { createConditionsHook } from '@saas-js/conditions-react'
const contactConditions = defineConditions({
fields: {
status: {
type: 'enum',
label: 'Status',
schema: z.enum(['lead', 'customer', 'churned']),
options: [
{ value: 'lead', label: 'Lead' },
{ value: 'customer', label: 'Customer' },
{ value: 'churned', label: 'Churned' },
],
},
company: { type: 'string', label: 'Company', schema: z.string() },
arr: { type: 'number', label: 'ARR', schema: z.number() },
},
})
export const contactUI = createConditionsHook({
definition: contactConditions,
valueEditors: {
string: StringValueEditor,
number: NumberValueEditor,
enum: EnumValueEditor,
},
conditionComponents: { FieldSelect, OperatorSelect, RemoveButton },
groupComponents: { CombinatorSelect, AddConditionButton, AddGroupButton },
conditionsComponents: { ConditionTree, ClearButton },
})definition and contexts are both optional. Omitting definition keeps the
per-call form (useConditions({ definition })). Pass shared contexts (from
createConditionsHookContexts) only when several compositions must resolve
the same providers.
Registered components are pre-bound onto the inferred instance:
function ContactFilters() {
const conditions = contactUI.useConditions({
defaultValue,
onValueChange({ value }) {
console.log(value)
},
})
return (
<conditions.Root>
<conditions.ConditionTree />
<conditions.ClearButton />
</conditions.Root>
)
}Component names may not shadow built-in instance members (Root, Subscribe,
ValueEditor, useFilter, …). Duplicates throw at creation.
Store modes
contactUI.useConditions({ defaultValue, onValueChange }) // uncontrolled
contactUI.useConditions({ value, onValueChange }) // controlled
contactUI.useConditions({ store: definition.createStore() }) // external storevalue is a single optional prop: leave it undefined for uncontrolled
usage, in the familiar React pattern. Controlled synchronization runs in an
effect and suppresses its own callback, so prop updates do not create
onValueChange loops. A parent that rerenders without accepting a change
reverts it.
The onValueChange details type is exported as
ConditionsValueChangeDetails<TDefinition>.
For non-hook ownership, createConditionsController({ definition }) and
createConditionsController({ store }) create the same controller shape.
Common data hooks
The selections every conditions UI needs are available as dedicated hooks —
no selector boilerplate. All of them are pre-bound on the instance and also
exported standalone (useConditionsValue(conditions),
useCondition(conditions, id), …) for code that holds a bare controller:
const query = conditions.useValue() // committed query
const root = conditions.useRoot() // root group
const isEmpty = conditions.useIsEmpty() // no conditions or groups
const condition = conditions.useCondition(id) // one condition, or undefined
const group = conditions.useGroup(id) // one group, or undefined
const draft = conditions.useDraft() // the active draft
const editDraft = conditions.useEditDraft(id) // draft while editing `id`
const addDraft = conditions.useAddDraft(groupId) // add draft for a group
const hasDraft = conditions.useHasAddDraft(groupId) // boolean, edit-stable
const matches = conditions.useFilter(contacts) // filtered subjectsEach hook subscribes through the memoized selector layer, so a component rerenders only when its selected data changes:
useConditionignores sibling updatesuseEditDraft/useAddDraftrerender only the chip that owns the draftuseHasAddDraftstays stable across draft keystrokes — use it in group nodes and render the draft chip in a child component
Selectors and scopes
For everything else, the selector hooks remain the escape hatch:
const count = useConditionsSelector(
conditions,
(state) => state.value.root.items.length,
)
const ids = useConditionsSelector(
conditions,
(state) => state.value.root.items.map((item) => item.id),
(a, b) => a.join(',') === b.join(','),
)
<conditions.Subscribe selector={(state) => state.value.root.combinator}>
{(combinator) => <span>{combinator}</span>}
</conditions.Subscribe>Use conditions.ConditionScope and conditions.ConditionGroupScope while
recursively rendering a tree. useConditionContext and
useConditionGroupContext return the stable controller/id pair and throw a
descriptive error outside the matching provider. useConditionSelector and
useConditionGroupSelector subscribe to individual nodes. All selector hooks
accept the optional isEqual argument.
Filtering subjects
const matches = conditions.useFilter(contacts) // reactive
conditions.filter(contacts) // one-shot, outside renderBoth delegate to definition.filter with the current committed query.
useFilter resubscribes when the query changes and memoizes against the
subjects reference.
For tables, databases, and sync replicas, pass the committed query to an adapter instead of filtering in memory — see TanStack Table, Drizzle, and Zero.
Drafts
conditions.draft.beginAddCondition({ parentId: groupId })
conditions.draft.updateDraft({ field: 'status' })
conditions.draft.updateDraft({ operator: 'in' })
conditions.draft.commitDraft({ value: ['lead', 'customer'] })
conditions.draft.beginEditCondition(conditionId)
conditions.draft.cancelDraft()Draft field and operator patches are typed against the bound definition.
Changing a field recalculates the available/default operator and resets the
value. Changing an operator normalizes draft shape for none, single,
multiple, and range. commitDraft accepts an optional final patch, so
"pick an option and commit" is one atomic call. Failed validation leaves both
the draft and committed query intact.
Subscribe without broad tree rerenders with useConditionDraftSelector.
Chip orchestration
useConditionChip owns the interaction flow every filter-chip UI repeats —
which panel is open, when an edit draft begins, and when a selection commits —
while rendering stays with the adapter:
function FilterChip() {
const { conditions, id } = contactUI.useConditionContext()
const condition = conditions.useCondition(id)
const draft = conditions.useEditDraft(id)
const chip = contactUI.useConditionChip(conditions, { condition, draft })
// chip.field / chip.operator / chip.value / chip.error / chip.panel
// chip.openPanel('field' | 'operator' | 'value')
// chip.selectField(id) — commits valueMode 'none', else opens value panel
// chip.selectOperator(id) — same commit-or-advance behavior
// chip.setValue(value), chip.apply(patch?), chip.close(), chip.remove()
}Opening a panel on a committed condition cancels any other active draft and
begins an edit draft. apply closes the panel on success and returns the
committed id.
Value editors
Editors receive the same normalized props whether they came from a field type, field, operator, or combination registry:
interface ValueEditorProps<TValue> {
value: TValue | undefined
onValueChange(value: TValue): void
field: ConditionFieldDefinition
operator: ConditionOperator
disabled?: boolean
readOnly?: boolean
error?: string
}Resolution precedence is:
fieldOperatorValueEditors['field:operator']fieldValueEditors[field]operatorValueEditors[operator]valueEditors[field.type]fallbackValueEditor
resolveValueEditor(context, resolved) is called last and may replace the
resolved editor. The standalone resolveValueEditor helper implements and
tests the same order.
The instance also exposes a bound conditions.ValueEditor component that
resolves and renders the editor for a field/operator pair, and renders
nothing for unknown pairs or operators without a value:
<conditions.ValueEditor
field={chip.field}
operator={chip.operator}
value={chip.value}
error={chip.error}
onValueChange={chip.setValue}
/>Async option sources
Calling useConditionOptions(conditions, { field, value }) is the opt-in
fetch boundary. No source is invoked until an editor calls the hook.
const { options, query, setQuery, loading, error, reload } =
useConditionOptions(conditions, { field: 'owner', value, debounceMs: 150 })The hook passes the field ID, current value, query, and an AbortSignal to
the definition's option source. It aborts superseded/unmounted requests and
guards against stale responses. Value changes do not refetch — sources read
the latest value when they run — and debounceMs debounces query-driven
refetches. Boolean fields without a source get default Yes/No options.
Reusable builders and design-system adapters
Bind a definition without losing inference. With a factory-bound definition
the definition option is omitted:
const ContactFilters = contactUI.withConditions({
render: ({ conditions }) => (
<conditions.Root>
<conditions.ConditionTree />
</conditions.Root>
),
})withConditionGroup binds reusable group UI to an explicit id or current
group scope.
Applications can extend an adapter once for their own components and field types:
const appConditions = contactUI.extendConditions({
valueEditors: { currency: CurrencyValueEditor },
conditionComponents: { ConditionSummary },
})Types and runtime checks reject duplicate names within a registry. One or two extension layers are recommended; build a new composition instead of creating an unbounded type chain.
API notes
- Registry-specific editor maps are separate properties rather than overloaded key conventions. This makes precedence visible and makes duplicate extension names statically checkable.
- Scope components are named
ConditionScopeandConditionGroupScopeto make it clear that they provide IDs. Expression rendering remains entirely owned by the adapter'sConditionTree. - The context helpers exported for advanced composition are
useConditionsRootContext,useConditionScopeContext, anduseConditionGroupScopeContext. They take the shared contexts object and back the instance-leveluseConditionsContext/useConditionContext/useConditionGroupContexthooks.