Skip to main content

Context and Bindings

See also: Expressions and Math · Story Engine · Dynamic Component System

A dynamically-built form has no hand-written parent to own its state, so state is declared as data too: variables, living in one shared application context. This page covers the context service, the two JSON node types that reference variables ({ $ref } values and { $gen } generators), and how inputs bind to variables. Source: libs/stretched-components/src/dynamic-host/context/ (deep-dive docs: dynamic-host/application-context.md, variable-bindings.md, control-flow.md).

ApplicationContextService — story → variable signals

context/application-context.service.ts, a root DI singleton holding a two-level map story → name → ContextVariable:

interface ContextVariable {
type: VariableType; // 'text' | 'number' | 'boolean' | 'date' | 'location' | 'list'
value: WritableSignal<unknown>; // the source of truth
field: Field<unknown>; // a form() view over `value`, created ONCE, lazily
}
  • A story is just a namespace string (first level of the map, optgroup labels in bind dropdowns). StoryTeller syncs a story's variables in on render (Story Engine); the builders sync while authoring.
  • Key API: declare(story, name, type, …) (idempotent — re-declaring with unchanged type+validators is a no-op that never rebuilds the field or resets state), syncStory(story, declarations) (reconcile: remove undeclared, declare the rest), renameStory(from, to), get(story, name), remove, and the variableRefs() signal (every variable, insertion-ordered — feeds bind dropdowns and autosuggest).
  • Because it's one root singleton and a variable's value signal and its Field view are the same underlying signal, everything is two-way for free: bind two inputs to one variable and editing either updates the other; the inspector (context/application-context-inspector.ts, <sc-application-context-inspector>) reads/writes the same signals directly. provideMockStore() (application-context.mock.ts) seeds example variables for Storybook/demos.

Validators

A variable can carry { expression, message } validators — the expression is the VALID condition (truthy = valid), evaluated by the safe parser (Expressions and Math) with $value, $name, $story.name refs read reactively, so cross-field rules ($value === $password) re-validate automatically. The field is built once with a single validate() rule that reads the validators signal — editing validators never rebuilds the field (rebuilding would reset touched/dirty and silently hide every error). Malformed expressions are skipped at render time; the builders surface syntax errors while typing.

{ $ref } value bindings — host-resolved

A literal field in a stored config can be replaced by a ref node: { "$ref": "story.name" } — plain JSON, round-trips through Firebase unchanged.

context/resolve-refs.ts exposes resolveRefs(data, context): a deep walk replacing each { $ref } with context.get(story, name)?.value(). DynamicConfigHost calls it inside its resolved computed, so reading each variable's value() signal ties the computed to it: variable changes → host re-applies the component's inputs → the component's _props() recomputes → re-render. No services required — props components are already reactive signal consumers (Dynamic Component System).

Guarantees:

  • Structural sharing — a subtree with no refs is returned by reference: configs without bindings incur no cloning and keep a stable identity (no spurious re-renders).
  • Graceful fallback — an unknown or unqualified ref ({ $ref: "income" }, no story) resolves to undefined; the component falls back to its own default.
  • Fine-grained — value edits flow through the specific value signal; the context map only churns on declare/remove/rename (authoring-time).

Helpers: isVariableRef(value), variableRef(story, name).

Authoring: any literal primitive field (string/number/boolean, incl. nested sub-fields) shows a "bind to a variable" toggle in the builders when a type-compatible variable exists (number field ↔ number variable, stringtext, booleanboolean) — see builder/dynamic-field-control.ts and Page Builder. Live demo: Storybook → Dynamic → Variable Binding (variable-binding.stories.ts) — a slider writes demo.income; a KpiCard bound to { $ref: 'demo.income' } re-renders with nothing wiring the two.

{ $gen } generators — reactive series

A { $gen: { count, let, value | item, round? } } node in place of a literal array is expanded by the same resolveRefs walk: any { $ref } inside let is resolved first, then generateSeries runs (Expressions and Math). Because the ref reads happen inside the host's computed, a generated chart series recomputes when the underlying story variable changes. Bad generators degrade to undefined (fail-soft, same contract as unresolved refs) — one bad node never throws through the tree.

Expression fields vs. value bindings

Two distinct patterns — pick by who consumes the variable (dynamic-host/variable-bindings.md has the full comparison):

Expression fieldValue binding
Stored asan expression string ("$income >= 1000"){ $ref: "budget.income" }
Resolved bythe component (injects the context, evaluates)the host (swaps ref for value before the component sees it)
Good forlogic the component understands (a gate's showWhen)feeding a plain value into a variable-unaware component (a KPI amount)
Authoring.meta({ control: 'expression' | 'template' }) on the Zod field → $ / ${} autocomplete inputthe "bind to a variable" toggle

Variables as input bindings

Serviced-input configs record their target variable as binding: { story, name } — qualified, so it survives serialization and cross-story references. At render time DynamicInputHost resolves it: an explicit [field] input always wins (a hand-written parent can still own the field); otherwise context.get(binding.story, binding.name).field — the once-built Field view over the variable's signal — is passed to the registry factory(field, data). No variable found → renders nothing. The registry entry's produces: VariableType drives the type-filtered bind dropdown in the builders (Dynamic Component System, Page Builder).

StoryTeller constructs these bindings implicitly for madlib sections: a ${income} token becomes an input host bound to { story: story.name, name: 'income' } (Story Engine).