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).
StoryTellersyncs 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 thevariableRefs()signal (every variable, insertion-ordered — feeds bind dropdowns and autosuggest). - Because it's one root singleton and a variable's
valuesignal and itsFieldview 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 toundefined; the component falls back to its own default. - Fine-grained — value edits flow through the specific
valuesignal; 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, string ↔ text, boolean ↔ boolean) — 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 field | Value binding | |
|---|---|---|
| Stored as | an expression string ("$income >= 1000") | { $ref: "budget.income" } |
| Resolved by | the component (injects the context, evaluates) | the host (swaps ref for value before the component sees it) |
| Good for | logic 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 input | the "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).