ngx-formad
v0.14.0
Published
Config-driven rendering and multi-step orchestration for Angular signal forms. You describe a form or wizard as data; one renderer walks it.
Maintainers
Readme
ngx-formad — config-driven forms and multi-step wizards for Angular signal forms
ngx-formad renders Angular forms from configuration. You describe a form — or a multi-step wizard — as data, and one renderer walks that data and produces the DOM. Field appearance (Material, plain HTML, or your own) and layout (CSS grid, Tailwind, or your own) are adapters you configure once. It sits on top of Angular's headless signal forms, which own value and validation but render nothing, and supplies the rendering and multi-step orchestration.
- Render from config — fields, layout, visibility, and dynamic components as data.
- Multi-step wizards — navigation, a completion gate, and cross-step prefill.
- Pluggable adapters — swap controls and layout independently; the defaults need no extra dependencies.
- Native validation — your
schemaruns on the realFieldTree; ngx-formad adds no validation layer of its own. - One non-Angular peer dependency —
formodel, the typed form description it renders.
Status — pre-1.0. This is
0.10.3, built on the signal-forms API that is experimental in Angular 21. Both the API underneath and this library's own surface may change in breaking ways before a 1.0; minor versions carry breaking changes, listed in the changelog. It has unit tests but no significant production track record yet; evaluate it on a small form first.
Quick look
Describe forms as data; get rendered, validated forms — and sequence them into a wizard. No templates either way.
A form — config in, a rendered form out:
import { UiFormConfigBuilder, email, text } from 'formodel';
import { FormRenderer, instantiateForm, FormConfig, provideFormadSuite } from 'ngx-formad/core';
import { defaultControls } from 'ngx-formad/template';
// IN — a form as configuration
const leadForm: FormConfig<{ email: string; company: string }> = {
model: () => ({ email: '', company: '' }),
ui: UiFormConfigBuilder.from({ email: '', company: '' })
.email(email('Email'))
.company(text('Company')),
};
@Component({
imports: [FormRenderer],
template: `<ngx-formad-form [state]="state" />`,
})
class LeadForm {
readonly state = instantiateForm(leadForm);
}
// once, at bootstrap: which formFields draw the fields (here the plain-HTML ones)
bootstrapApplication(App, { providers: [provideFormadSuite({ controls: defaultControls })] });OUT — rendered with the plain-HTML formFields of `ngx-formad/template` and the CSS-grid layout:
┌ Email ─────────────────────────────┐
│ │
└────────────────────────────────────┘
┌ Company ───────────────────────────┐
│ │
└────────────────────────────────────┘A wizard — sequence forms into steps; a shared key prefills the next one:
import { link, StepperBuilder, StepperComponent } from 'ngx-formad/core';
// IN — two forms sequenced; `Company` typed on step 1 prefills step 2
const wizard = new StepperBuilder()
.withState({ company: '' })
.createForm('lead', leadForm)
.createForm('billing', billingForm) // another FormConfig with a `company` field
.createStep((step) =>
step.createPage((page) => page.addForm('lead', { fieldValueBindings: (f, s) => [link(f.company, s.company)] }), {
heading: 'Your details',
}),
)
.createStep((step) =>
step.createPage((page) => page.addForm('billing', { fieldValueBindings: (f, s) => [link(f.company, s.company)] }), {
heading: 'Billing',
}),
)
.build();
@Component({
imports: [StepperComponent],
template: `<ngx-formad-stepper [stepper]="wizard" />`,
})
class SignupWizard {
readonly wizard = wizard;
}OUT — a navigable wizard with a completion gate and autofill:
Step 1 of 2 — Your details Step 2 of 2 — Billing
┌ Email ───────────────┐ ┌ Company ─────────────┐
│ │ Next → │ Acme Inc. │ ← prefilled
└──────────────────────┘ ───────▶ └──────────────────────┘ from step 1
┌ Company ─────────────┐ ┌ VAT ID ──────────────┐
│ Acme Inc. │ │ │
└──────────────────────┘ └──────────────────────┘
[ Back ] [ Next → ] [ Back ] [ Submit ]Install: pnpm add ngx-formad formodel.
What is ngx-formad?
ngx-formad is an Angular library that turns a declarative form description into a rendered, validated form — and stitches many such forms into a multi-step wizard with shared state. Angular signal forms are headless: they own value, validation, and field state, but you hand-write a template for every field. ngx-formad supplies the missing renderer and the multi-step orchestration, while leaving validation and field state entirely native.
It is built for teams that render many forms from configuration rather than hand-writing each one: CMS and admin panels with user-defined content types, low-code/no-code builders, onboarding and checkout wizards, and survey engines.
When should I use it?
Use ngx-formad if your forms are numerous, structurally similar, conditional, JSON/config-driven, or multi-step — because content types, product configurators, or admin screens multiply faster than you can hand-write templates.
Skip it if you are building one bespoke form. Native signal forms plus a hand-written template will be smaller and clearer, and this adds nothing.
What it gives you
- Config-driven rendering — fields, nested groups, and repeating arrays drawn from a
FormConfig, with no per-field template. - Pluggable adapters — control and layout axes you swap independently; a ready-made Angular Material control adapter ships in the box.
- Multi-step wizards — navigation with a completion gate folded from each field's native
valid(). - Flows that bend to the data — conditional, optional, lazy, deferred and dynamic steps; branching that rejoins, forking that returns a typed result, and rings that repeat a pass and fold it into a state.
- Cross-step shared state — directional prefill that seeds a later field and leaves a user's edit untouched.
- Native validation, unchanged — your
schemaruns on the realFieldTree; nothing is re-implemented. - Headless core — drive the
StepperControllerdirectly for a fully custom wizard UI.
Install
pnpm add ngx-formad formodel@angular/core, @angular/common, and @angular/forms are peer dependencies.
Works with Angular 21.2+ and Angular 22 on the @angular/forms/signals API
(experimental in 21). One package covers both majors — the slice of the API it uses
(form, FieldTree/FieldState, FormField, FormValueControl, createComponent
bindings) is identical across them, and the Angular-21-built artifact is finalized by
the partial-compilation linker in any Angular ≥21 app.
The rest of this guide builds the editor UI of a small headless CMS — content types defined as data, rendered into editor screens and a publishing wizard. You meet three pieces in order, each one composed from the last: the form, the page, and the stepper.
The form
A content type — Article, Author, Product — is just a list of fields. In ngx-formad
that list is a FormConfig: a model factory, a formodel ui description, and an
optional native schema. Hand it to <ngx-formad-form> and the renderer draws every
field and keeps a live, fully native field tree behind them. No template, no per-field
boilerplate — this is the atom that the page and the stepper are built from.
import { Component } from '@angular/core';
import { UiFormConfigBuilder, text, textarea } from 'formodel';
import { FormRenderer, instantiateForm, FormConfig } from 'ngx-formad/core';
type Article = { title: string; slug: string; body: string };
const articleForm: FormConfig<Article> = {
model: () => ({ title: '', slug: '', body: '' }),
ui: UiFormConfigBuilder.from({ title: '', slug: '', body: '' })
.title(text('Title'))
.slug(text('Slug', { placeholder: 'my-first-post' }))
.body(textarea('Body')),
};
@Component({
imports: [FormRenderer],
template: `<ngx-formad-form [state]="state" />`,
})
export class ArticleEditor {
// `instantiateForm` calls native `form()` and creates the formFields, so it runs
// here, in an injection context. The renderer only draws what the state holds.
readonly state = instantiateForm(articleForm);
}With no adapters configured this renders native HTML controls in a CSS grid — no stylesheet, no build step. The default grid is one column, in declared order; putting two fields side by side is the layout overlay's job, below:
┌ Title ─────────────────────────────────────────────┐
│ │
└─────────────────────────────────────────────────────┘
┌ Slug ──────────────────────────────────────────────┐
│ my-first-post │
└─────────────────────────────────────────────────────┘
┌ Body ──────────────────────────────────────────────┐
│ │
└─────────────────────────────────────────────────────┘A model factory that returns a value gives every instance a fresh signal over it. To
own the model, say to follow an input, return a WritableSignal of yours from it; the
state holds that very signal, so a write to it is a programmatic write (the fields stay
pristine) and a user edit lands in it:
export class ArticleEditor {
readonly article = input.required<Article>();
readonly state = instantiateForm({
...articleForm,
model: () => linkedSignal(() => ({ ...this.article() })),
});
}model may also be the signal itself rather than a factory; then every instance of that
config shares it - in a stepper's pool too, where a StepRevisit.Fresh step comes back
with what was typed.
instantiateForm never reads the model's value, so a model that computes it - a
linkedSignal over an input.required - is not asked before the inputs are set. The
field tree, the view, the formFields' mounts and the effects are all built at once; only
finding a field in the tree reads the value (the tree knows its children by it), and
that waits until the row is first drawn. Two things are yours to keep out of the
constructor: reading the form - state.model(), state.rootForm().value(), even
state.rootForm.title, which looks the field up - and a factory that reads the input
itself, () => signal({ ...this.article() }); keep the read inside the linkedSignal's
computation. A config error (a field the model lacks) shows when its row is first drawn.
state.rootForm is the live native FieldTree, so rootForm.title().value() and
the field's validation are entirely native — a fact the page and the stepper lean on.
state.view is the form's view: the container's layout classes and one row per node —
a control row carrying the injector its formField component is created from, a group,
array or layout row carrying its own nested level. The renderer walks that view and
nothing else.
Custom layout: place fields and components in a responsive grid
By default a form renders its fields in declared order. To arrange them — put two
fields side by side, drop a component between fields, group a cluster into its own
sub-grid — add a layout overlay to the FormConfig. Field-building in ui is
untouched; the overlay only decides order, grid placement, and where components sit.
Build it with formLayout(...):
import { formLayout, FormConfig } from 'ngx-formad/core';
import { PasswordChecklist } from './password-checklist';
type Signup = { first: string; last: string; password: string; repeat: string };
const signupForm: FormConfig<Signup> = {
model: () => ({ first: '', last: '', password: '', repeat: '' }),
ui: UiFormConfigBuilder.from({ first: '', last: '', password: '', repeat: '' })
.first(text('First name'))
.last(text('Last name'))
.password(password('Password'))
.repeat(password('Repeat password')),
layout: formLayout<Signup>()
.cols({ base: 1, md: 2 }) // one column on mobile, two from `md` up
.field('first', { colSpan: { md: 1 } })
.field('last', { colSpan: { md: 1 } }) // first | last, side by side ≥ md
.field('password', { colSpan: { base: 1, md: 2 } })
.field('repeat', { colSpan: { base: 1, md: 2 } })
.build(),
};An overlay draws only what it names: a field of ui that no field(), fields() or
control() places is left out. In dev mode the form warns once per layout about such
fields; a field left out on purpose (a value the model carries but the page never shows)
is declared .omit('key'), and the warning skips it. omit() is about the view and
nothing else: the field is still in the schema, so it still validates and still counts
towards the step's completion — a required field nobody draws holds Next forever.
Excluding a field from validity is the schema's job (hidden(), below). And omit()
does not excuse a customField() from control(): that refusal is a type, and a layout
cannot carry its omitted keys into it without threading them through FormConfig too, so
a customField() the page never draws should not be declared as one.
A field whose editor is a specific FormValueControl component, not a registered kind,
is declared customField() in ui and placed with .control(key, config). The layout only
places: the renderer binds FormField to the field itself, and everything else the
component takes is declared where the form is instantiated (see Bindings):
type SourcePick = { source: string };
const pickForm = defineForm({
model: (): SourcePick => ({ source: '' }),
ui: { source: customField() },
layout: formLayout<SourcePick>().control('source', { component: RadioCards, placement: { colSpan: 2 } }).build(),
});control(key, config) takes a FieldComponentConfig<M[K]>, so the component must be a
FormValueControl of the field's own value type; the layout remembers which component
sits on which field, so the bindings of that field are typed by it. A customField()
left without a control() placement throws when the form is instantiated.
A widget that is not a field - a running total, a hint card, a lookup button - takes a
layout slot of its own with .component(config). It is bound the way a page component
is: bindings gets the form context and returns Angular's own Binding[], read once
at mount, so a getter inside inputBinding is what keeps an input following the model:
.component({
component: OrderTotal,
bindings: (ctx) => [
inputBinding('total', () => ctx.model().qty * ctx.model().price),
outputBinding('clear', () => ctx.model.update((order) => ({ ...order, qty: 0 }))),
],
placement: { colSpan: 2 },
})Nothing ties the component to a field: no FormField, no validity, no place in the
schema. A widget that edits a value is a field, declared customField() and placed
with control().
A content() gate reads the form context — { model, ui, fields }: the model as a
writable signal, the authored ui and the placed fields. Never the field tree, and never
the step context; a gate over cross-step state belongs on the page, not in the overlay.
.content((c) => c.p('Verified against the catalogue.'), {
visibleWhen: (ctx) => ctx.model().verified,
})Group a cluster into its own sub-grid with .row(build, cfg) — the row's own grid
plus its placement in the parent grid:
.row((row) => row.fields('city', 'postCode', 'country'), { cols: 3, colSpan: 2 })Prose between the fields - a group heading, a hint under a cluster - is .content(build),
the same tag builder pages use, gated on the same context:
.content((c) => c.h3('Contact for this report'))
.fields('name', 'phone')
.content((c) => c.p('We only call about this report.', { className: 'hint' }), {
visibleWhen: (ctx) => ctx.model().phone.length > 0,
})Fields left out of the overlay still validate (they are in the schema) but do not render — list every field you want shown.
Responsive layout is Tailwind's job, not ours. colSpan/cols accept a constant
(2) or a per-breakpoint map ({ base: 1, md: 2 }). The zero-dependency default
(cssLayout) resolves only the base value — inline styles cannot carry media
queries. For real responsive layouts register tailwindLayout: it composes Tailwind's
own utilities (grid grid-cols-1 md:grid-cols-2, col-span-6, gap-4), so the
library ships no stylesheet and no generator, and your build emits exactly the classes
your configs name.
import { provideFormadSuite } from 'ngx-formad/core';
import { tailwindLayout } from 'ngx-formad/tailwind';
provideFormadSuite({ layout: tailwindLayout /* , controls, stepper */ });Tailwind only sees literals in your source, and these class names are composed at runtime, so name them once beside your import — in v4 that is one line of CSS:
@import 'tailwindcss';
@source inline("{sm:,md:,lg:,xl:,}{grid-cols-,col-span-,col-start-}{1,2,3,4,5,6,7,8,9,10,11,12}");
@source inline("gap-{0,1,2,3,4,5,6,8,10,12}");The breakpoint keys of a responsive value are Tailwind's screens, so they waterfall upward the way Tailwind's do.
Tour
A real CMS editor screen is more than one form. It is a page: a few forms side by
side, a dynamic component or two (a publish-status banner, a live preview), and blocks
that appear only when relevant — arranged in a layout. You author a page with the
builder. createPage hands you a callback where you addForm (by name, from a typed
pool — or addForm(name, template) with a config or loader given right there, keyed
and typed by that name the same way), addComponent, addContent (prose - see
below),
insertRow, and gate any node — a row and the page itself included — with
visibleWhen. Visibility is data: the renderer auto-@ifs on it, so you never write
the @if. The same
builder scales to many steps — here we use a single one.
import { inputBinding, signal } from '@angular/core';
import { StepperBuilder, StepperComponent } from 'ngx-formad/core';
const status = signal<'draft' | 'scheduled'>('draft');
const editorPage = new StepperBuilder()
.createForm('article', articleForm) // the form from above, in a typed pool
.createForm('seo', seoForm) // title + meta description, another FormConfig
.createStep((step) =>
step.createPage(
(page) =>
page
.addForm('article') // place the article form by name
.insertRow((row) => row.addForm('seo'), { layout: 'cols-2' }) // a layout row
.addComponent(ScheduleNotice, {
bindings: [inputBinding('when', () => status())], // Angular's own bindings, as createComponent takes them
visibleWhen: () => status() === 'scheduled', // conditional, as data
}),
{ heading: 'Edit article' },
),
)
.build();
@Component({
imports: [StepperComponent],
template: `<ngx-formad-stepper [stepper]="page" />`,
})
export class EditorScreen {
readonly page = editorPage;
}That is one screen composed from configuration: the Article and SEO forms render the
way the form section described, the row arranges SEO into its own group, and
ScheduleNotice exists in the DOM only while status() is 'scheduled' — the
renderer added and removed it for you. Flip the signal back to 'draft' and the
component is gone, with no @if anywhere in your code.
Content — prose as data
A page is rarely only forms. There is the sentence above the field, the note that
appears when a rule bites, the "or" between two blocks, the hint whose middle word is
a link. All of that used to need a component written for one paragraph, because a
content slot was a bare <div>. addContent closes that hole: the builder's methods
are the tags, and what comes out is the same node tree as everything else here.
page
.addContent(
(c) =>
c.div({ className: computed(() => `callout ${tone()}`) }, (d) =>
d
.span('info', { className: 'material-symbols-outlined', attrs: { 'aria-hidden': true } })
.p('We found ')
.insert((p) => p.strong(computed(() => `${products().length} products`)))
.continue(' in your basket.'),
),
{ layout: 'callout' },
)
.addForm('issue')
.addContent((c) =>
c
.p('Check the ')
.insert((p) => p.a('warranty terms', { href: '/terms' }))
.continue(' first.')
.ul((list) => list.li('On the product page').li('In your order confirmation')),
);- A tag takes its text, its options and its children -
p('A line', { className }, (p) => …)- each optional, sorted out by shape: an object is options, a callback that takes the builder is the children, and a signal is text. So a container isdiv({ className }, (d) => …), or justul((list) => …). insert(build, at?)writes inside the tag you just added, and leaves the cursor there;continue(text, at?)keeps writing text in it. That is how a sentence with a link or a<strong>in the middle stays one flat chain instead of nesting itself in a callback - and both can repeat, so a paragraph can hold two links and the words between them.atputs the run at a position among that tag's children.- Text may be a signal.
p(computed(() => count() + ' products'))follows it, and a signal is its own binding, so a change touches one DOM node. A constant stays a constant on the node - written once, subscribed to never - which also means a fully static block is plain data, JSON included. Nothing here takes a bare closure: a value that changes isSignal<T>, Angular's own type. - Content renders as part of change detection, so it is in server-rendered output; hydration of a content block has not been verified yet, so treat SSR + hydration as untested rather than supported.
visibleWhengates a tag (and its subtree) or the whole block; a gated tag stays in the DOM and is hidden in place (hiddenplusdisplay: none) rather than torn down and rebuilt, so its listeners and its identity survive the round trip.- Classes are the whole styling surface. The library ships no look for content: the
classNameyou pass is yours, resolved by nothing. - Nothing is interpolated as markup, and what a config may ask for is bounded
three ways - all enforced by the renderer, so a tree that arrived as JSON is bounded
too: the tag vocabulary is closed (
CONTENT_TAGS: structure, text, lists, links, images, a button - an unknown tag draws nothing), attribute names beginning withonare refused, and URL-bearing attributes (href,src, …) are scheme-checked, sojavascript:anddata:text/htmlnever reach the DOM. It is not a full sanitizer -styleis allowed, as it is in Angular's own bindings - so genuinely untrusted text still belongs behind a component you control. Anything interactive beyond a link or a button isaddComponent's job.
Content is drawn by Renderer2, not a template, for one structural reason: recursing
through a component would put its host element between a <ul> and its <li>. The
tree renders exactly as authored, with no wrapper inside it.
The same builder works inside a form, as formLayout(...).content(build, { visibleWhen })
The stepper
Publishing an article is a sequence: Basics → SEO → Review. A stepper turns the
pages above into ordered steps and adds the three things a wizard needs: navigation,
a completion gate (you cannot advance past an invalid step — it is folded from the
native valid() of every field), and cross-step shared state with directional
autofill, where a value entered early flows forward into a later field and is never
clobbered once the user edits it. It is the same builder, with more steps plus
withState and fieldValueBindings.
const wizard = new StepperBuilder()
.withState({ slug: '' }) // declares the cross-step shared state
.createForm('article', articleForm)
.createForm('seo', seoForm)
.createStep((step) =>
step.createPage(
(page) => page.addForm('article', { fieldValueBindings: (f, s) => [link(f.slug, s.slug)] }), // article.slug ↔ shared.slug
{ heading: 'Basics' },
),
)
.createStep((step) =>
step.createPage(
(page) => page.addForm('seo', { fieldValueBindings: (f, s) => [link(f.slug, s.slug)] }), // shared.slug → seo.slug (prefill)
{ heading: 'SEO' },
),
)
.build();
@Component({
imports: [StepperComponent],
template: `<ngx-formad-stepper [stepper]="wizard" />`,
})
export class PublishWizard {
readonly wizard = wizard;
}fieldValueBindings is a callback handed typed field references and typed store
slots; link(field, slot) joins a pair, and the list it returns is the form's links.
Nothing is named by string, and the pair is checked both ways: the key must exist in
withState and carry the field's value type. A nested field is reached by walking the
references (fieldValueBindings: (f, s) => [link(f.address.city, s.city)]).
A key's value may be a WritableSignal of your own: withState({ slug: this.slug })
keeps that signal as the key's cell, typed by what it holds, so a commit lands in it and
a write to it is what the next seed reads. The store never resets it (reset() and a
ring's new pass leave it alone); it is yours.
At runtime the shell shows Basics with its heading and a Next that refuses to advance until the step is valid — that is the completion gate. Type a slug and advance, and SEO opens with its slug already filled in, because both fields bind the same shared key and the value flowed forward. Edit it on SEO, go Back, come Forward again, and your edit stays: the prefill only writes into a pristine field, so it never overwrites what a user typed.
That safety is by design. A two-way binding between steps would oscillate; this sync
is directional and discrete, with only two edges — shared → field on entry (only
while pristine), and field → shared on commit (Next / Back), once. The pristine
gate reads the native dirty() flag, and the seed writes through the field's value
signal, which
per the field-state docs
does not mark a field dirty — so a seed never trips its own gate, and nothing can loop.
…and the flow can bend to the data
A real publishing wizard isn't a straight line. The same chain bends to the input, and none of it is imperative — it's all data the engine reads from the live context:
- Nested models render as nested fieldsets — an Article with an
seo: { title, metaDescription }object becomes an SEO group with no extra work. See Nested groups. - A step can be conditional or optional —
createStep(build, { activeWhen })drops a step that doesn't apply;{ optional: true }lets the user skip it. - The end can fork. Publishing splits — publish now vs schedule — into two short arms with different egress, each under its own key:
.fork((ctx) => ctx.shared.mode(), {
now: (arm) => arm.createStep(/* confirm */, { name: 'confirm' }),
schedule: (arm) => arm.createForm('when', whenForm)
.createStep(/* pick date */, { name: 'when' }),
})
.build(); // → Stepper<…, { now: { confirm?: … } } | { schedule: { when?: { when: When } } }>So the arc scales: a form renders one content type (nested or flat), a page composes forms and components into a screen, a stepper sequences pages — gating, branching, and forking on the data — into a wizard that returns a typed result. The rest of this document is the reference.
Reference
Validation
ngx-formad does not re-invent validation. You attach a standard signal-forms
schema; the native field tree validates, and the default control prints the message
inline once the field is touched.
import { email, minLength, required } from '@angular/forms/signals';
const seoForm: FormConfig<Seo> = {
model: () => ({ slug: '', metaDescription: '' }),
ui: /* …formodel ui… */,
schema: (p) => {
required(p.slug, { message: 'A slug is required to publish.' });
minLength(p.metaDescription, 50, { message: 'Aim for 50+ characters for SEO.' });
},
};After blurring an empty slug, the default control emits:
<label class="ngx-formad-field">
<span>Slug<span class="ngx-formad-req" aria-hidden="true"> *</span></span>
<input type="text" aria-invalid="true" aria-describedby="ngxf-7-err" aria-required="true">
<small class="ngx-formad-error" id="ngxf-7-err" role="alert">A slug is required to publish.</small>
</label>Because errors live on the native field tree, you can also read them yourself —
state.rootForm.slug().errors(), .valid(), .touched() — which is exactly how the
stepper folds its completion gate and lists its blockers.
Nested groups
A model with nested objects renders as nested fieldsets — you don't flatten it by
hand. formodel describes an object key as a group ({ label, maxCols, controlsConfig })
and the renderer descends into it; form(model) already nests the FieldTree
(root.address.city) and folds validity over the whole tree, so nothing else changes.
type Account = { name: string; address: { city: string; street: string; zip: string } };
const accountForm: FormConfig<Account> = {
model: () => ({ name: '', address: { city: '', street: '', zip: '' } }),
ui: UiFormConfigBuilder.from({ name: '', address: { city: '', street: '', zip: '' } })
.name(text('Name'))
.address({ label: 'Address', maxCols: 2 }, (b) =>
b.city(text('City')).street(text('Street')).zip(text('ZIP')),
),
schema: (p) => {
required(p.name);
required(p.address.city); // validate nested paths natively
},
};That renders Name, then an Address fieldset with City/Street/ZIP in a 2-column
grid. Groups nest arbitrarily deep. Each leaf in state.fields carries its full
path (['address','city']), so a custom chrome's validation summary can address
nested fields, and the completion gate already counts them. Shared links reach nested
fields too — fieldValueBindings: (f, s) => [link(f.address.city, s.city)] links a nested field to a shared
key, so cross-step prefill works at any depth.
Repeating arrays
A model array (T[]) is a repeating group: formodel describes it with array(itemConfig),
and the renderer draws each item from itemConfig, with Add/Remove controls.
type Order = { items: { sku: string; qty: number }[] };
const orderForm: FormConfig<Order> = {
model: () => ({ items: [] }),
ui: UiFormConfigBuilder.from({ items: [] as { sku: string; qty: number }[] }).items(
array(UiFormConfigBuilder.from({ sku: '', qty: 1 }).sku(text('SKU')).qty(number('Qty')), {
label: 'Line items',
addLabel: 'Add item',
}),
),
};Each item renders the itemConfig (nested groups included), bound to that item's
field tree; Add appends a blank item and Remove drops one, both immutable
updates of the array field's value signal so validity re-folds automatically. The
addable/removable/addLabel/removeLabel flags from the node control the
affordances.
Conditional, optional & lazy steps
Real wizards branch. createStep takes options — activeWhen, optional, defer —
that make a step conditional, skippable, or deferred, all as data. Two paths
(say an admin vs. a regular account) are expressed by gating their steps on a
condition; the common steps that follow are ungated, so the branches rejoin
automatically. Because both branches link to the same shared keys, everything
downstream is branch-agnostic — the shared state is the converging interface.
import { DeferTrigger, StepperBuilder } from 'ngx-formad/core';
new StepperBuilder()
.withState({ accountType: 'user' as 'user' | 'admin' })
.createForm('basics', basicsForm)
.createForm('adminConfig', () => import('./admin').then((m) => m.adminConfigForm)) // lazy
.createForm('userMeta', userMetaForm)
.createForm('review', reviewForm)
.createStep((s) => s.createPage(/* Basics */), { name: 'basics' })
.createStep((s) => s.createPage(/* Advanced */), {
activeWhen: ({ steps }) => steps.basics.basics.model().accountType === 'admin', // (a) a prior step
defer: DeferTrigger.Idle, // render this heavy step inside @defer
})
.createStep((s) => s.createPage(/* Settings */), {
activeWhen: ({ shared }) => shared.accountType() !== 'admin', // (b) shared state
})
.createStep((s) => s.createPage(/* Notes */), { optional: true }) // shows a Skip button
.createStep((s) => s.createPage(/* Review */)) // ungated — admin & user paths rejoin here
.build();activeWhen(ctx)is typed against the wizard you're building —ctx.stepsis the record of the NAMED steps before this one, each exposing the live instances it placed by pool name (steps.basics.basicsis aFormState:.model(),.rootForm), andctx.sharedis yourwithStateshape as one signal per key (both autocomplete; unknown keys are compile errors). It reads from three sources: (a) a previous step's live instance viasteps, (b) the cross-stepsharedstate, and (c) anything you close over — ignore the arg and read an external signal. A gate is asked only once every step before it has been entered or skipped, so an ungated predecessor is there when the gate reads it —steps.basicsabove needs no?.. A step underactiveWhenmay have been skipped, so it is| undefinedeverywhere; a branch arm's step is| undefinedto everything after the branch, but there to the later steps and nested selectors of its own arm — whatever asks inside the arm runs only because the arm did. Until navigation reaches a gated step, it is undecided: off the rail and out oftotal, its gate unasked. It is reactive: flip the value and the step appears or disappears, and the sequence re-shapes live. Unnamed steps are reachable by authored index only — fragile under reordering — so name any step another one reads.optional: truemakes the step skippable — the bundled shell renders a Skip that advances past the completion gate.completeWhen(ctx)is an extra gate ANDed with the form fold, reading the same typed context asactiveWhen(plus anything you close over). A step whose body is a dynamic component rather than a form contributes no fields to the fold, so it is otherwise vacuously complete and Next is always enabled;completeWhenlets it gate Next on its own state — e.g.{ completeWhen: ({ shared }) => shared.consented() }. Pair it withincompleteMessage("Accept the terms to continue") — that is what the blocked-Next explainer shows when this guard is what holds. It is reactive. For a component that carries a value (a picker, a selection grid), put the value in a form field and place the component withformLayout().control(...): its validity gates Next declaratively and its errors explain themselves.- Lazy forms — pass
createForm(name, () => Promise<FormConfig>)and the config is fetched only when its step is reached (eager steps stay synchronous). The pool type still recordsFormConfig<M>, soaddForm/fieldValueBindings/stepsare unchanged. - A template given at the page —
addForm(name, template, cfg?)places a config, or a loader for one, where it is used instead of naming a pool entry;steps.<step>.<name>and the committed record read it exactly like a pool form. The pool stays for what several steps share; two arms of a branch may place their own variant under one name. A step's instances key by name, so placing one name twice in a step is refused as the step is built. - Seeding from a predecessor —
addForm('details', { initial: ({ steps }) => ({ manufacturer: steps.maker?.maker.model().manufacturer }) })starts THIS instance from another step's live value when the step comes to life. It is the LOCAL channel; a shared link (throughfieldValueBindings) is the global one — the same value should not travel both ways. An entry whose value isundefinedleaves the field's own default alone. - What a revisit does is the author's call, per placement. By default a step the
user comes back to is exactly as they left it:
initialran once, and the fields keep what was typed — an address page does not reset because the cart was confirmed. Two opt-in policies, both enums:addForm(name, { revisit: FormRevisit.Reseed })readsinitialagain from the current context on every entry and writes it into the fields the user has not edited (the same pristine gate shared links use);createStep(build, { revisit: StepRevisit.Fresh })drops the step on entry and builds it again, pages and forms alike, so nothing typed there survives. Shared links are reapplied to pristine fields on every entry regardless. - A model of your own —
addForm('lead', { ...leadForm, model: () => linkedSignal(() => ({ ...this.lead() })) })lets an input reach the form without an effect. The factory runs for every instance, so aStepRevisit.Freshstep or a ring's pass gets a newlinkedSignal, back at the input's value. Amodelthat is a signal itself (not a factory) is the model of every instance instead, and they come back with what it holds.initialand shared links still write into it; a new input value replaces the whole model, seeds and edits included, as alinkedSignaldoes. - A page node bound and gated on the context —
addComponent'sbindingstakes a callback over the same typed context beside the plain array, so a component on an ordinary step binds to what earlier steps hold:bindings: (ctx) => [inputBinding('plan', () => ctx.steps.basics.basics.model().plan)]. The callback runs once, when the step is materialized, over a context of getters — read it inside the binding's own getter and the input follows the live tree afterwards, a predecessor rebuilt byStepRevisit.Freshincluded.visibleWhentakes the same context everywhere a page has one —addComponent,addForm,addContent,insertRowandcreatePageitself; it is acomputedthe renderer@ifs on, so it follows what it reads by itself. A container's gate takes its children with it, completion included: a form inside a hidden row (or on a hidden page) is not drawn and does not hold Next, so a required field nobody can see cannot block the wizard. Neither callback needspages: StepPages.OnEntry— the page's structure does not depend on the context here, only one input and one@ifdo. - Pages built on entry — a step declared
pages: StepPages.OnEntry,createStep((step, ctx) => …, { pages: StepPages.OnEntry }), builds its pages when it is entered, so which pooled forms it places and what prose it shows can follow the user's earlier answers (ctx.steps,ctx.shared). The callback must be pure. By default (StepPages.Once) a step is built once, at authoring time, and a callback that takesctxis refused at compile time. In-form conditionality stayshidden()/visibleWhen; building on entry covers only what those cannot: the page's structure. One input or one gate over the context is not that — those are the callbacks above. deferrenders the step's body inside an Angular@deferblock (DeferTrigger.Idle,.Viewportor.Interaction) with a placeholder while it hydrates.
The built Stepper is the source of truth for these types: CommittedOf<typeof built>
is the shape of everything it commits — { [step]?: { [poolName]: model } } — so a
host narrows the erased stepCommitted record once, at the boundary. Hosts compose
the same primitives the shell uses — effectiveSteps(steps, ctx) (the conditional
filter), resolveStepForms(pages, pool) (await lazy configs) and
materializeStep(pages, options) — and drive their own @defer with any trigger.
Branching
Gating every step of a path with the same activeWhen is tedious, so branch groups
them: a selector picks a branch by key, and each branch authors its own steps. It
is pure sugar — every branch step compiles to an activeWhen-gated step, so the
runtime is unchanged and branches rejoin automatically at the next ungated step.
new StepperBuilder()
.withState({ plan: 'free' as 'free' | 'pro' })
.createForm('start', startForm)
.createForm('billing', billingForm)
.createForm('seats', seatsForm)
.createForm('done', doneForm)
.createStep((s) => s.createPage(/* Start */))
.branch((ctx) => ctx.shared.plan(), {
// ▲ ctx is the same typed context as `activeWhen`: read shared or steps
pro: (b) =>
b
.createStep((s) => s.createPage(/* Billing */))
.createStep((s) => s.createPage(/* Seats */), { activeWhen: ({ shared }) => shared.plan() === 'pro' }),
free: (b) => b.createStep((s) => s.createPage(/* Free note */)),
})
.createStep((s) => s.createPage(/* Done */)) // ungated — both branches rejoin here
.build();The selector is typed against the wizard (ctx.shared.plan autocompletes); returning
null selects no branch — nothing is guessed. It is asked once per walk of the
sequence, however many arms the branch has. An arm is an ordinary linear chain over
the level's context — a StepperBuilder, so it may createForm, withState,
provide, nest a branch(...) or a flow, and take a fragment
through use(...); its pool and state merge into the level's, and a step's own
activeWhen is ANDed with the branch guard. What an arm may not do is fork: a fork
does not rejoin, and branch refuses one at authoring time. Because branching reduces to
conditions, the steps source works too: branch on a named predecessor's live answer,
not just on shared — the selector is asked once the steps before the branch are entered,
so that predecessor is there. A branch step is gated by nature, so successors see it as
| undefined.
Forking
Branch and fork are different intents:
| | branch | fork |
| --- | --- | --- |
| Idea | Same destination, different road | Different destinations |
| Rejoin | Yes — common steps follow | No — each arm is terminal |
| Egress | The committed steps, arms included | The chosen arm's committed steps under its key: { a: Ea } \| { b: Eb } |
| Arm | A linear chain over the level's context | The same |
| Returns | The chain (keep going) | A Branch — only build() |
Use fork when the input picks what the wizard becomes and each path ends
somewhere else. The arms are authored exactly like a branch's; fork keys each arm's
egress by its name and unions them into the wizard's result type, then returns a
terminal, so nothing can follow it. An arm may fork again — its union then sits under
its key.
const wizard = new StepperBuilder()
.withState({ kind: '' as '' | 'individual' | 'business' })
.createForm('start', kindForm)
.createStep((s) => s.createPage(/* choose kind */))
.fork((ctx) => ctx.shared.kind(), {
individual: (arm) =>
arm
.withState({ ssn: '' }) // arm-local state, merged into the wizard's
.createForm('person', personForm)
.createStep((s) => s.createPage(/* personal */), { name: 'person' }),
business: (arm) =>
arm
.withState({ vat: '' })
.createForm('company', companyForm)
.createStep((s) => s.createPage(/* company */), { name: 'company' })
.createStep((s) => s.createPage(/* seats */), { name: 'seats' }),
})
.build();
// build() → Stepper<…, { individual: { person?: { person: Person } } }
// | { business: { company?: { company: Company }; seats?: … } }>Each arm's egress is what a linear flow of its own would hand out — its named steps' committed models — and the key it sits under is the arm's name, so the union needs no hand-written discriminant. The shell surfaces it on finish:
<ngx-formad-stepper [stepper]="stepper" (completed)="onResult($event)" />
// onResult(r) → narrow with `'individual' in r`. (Erased to `unknown` at the output; a
// custom chrome reads the typed controller.result() directly.)The selector reads the same typed context a gate does — a predecessor before the fork
is there, not optional — because the result is asked only once the flow stands on its
last step. A selector value with no matching arm (e.g. the initial '') simply means
"no arm chosen yet" — no arm steps, result() is null. Because the decision is
committed before navigation advances, choosing an arm on a step and pressing Continue
moves straight into it. A linear flow without a fork hands its committed record out
the same way: completed carries what stepCommitted last emitted.
Fragments — one chain, many wizards
Every builder — the root, an arm, a nested flow, a ring's pass — is the same chain,
FlowBuilder. A piece of wizard you author once is a function over it, with the
context it needs spelled as a requirement:
type NeedsAccount = RuntimeStepperContext & { readonly shared: SharedSignals<{ apiUrl: string; signedIn: boolean }> };
export function withDevices<TForms, TKinds extends string, TCtx extends NeedsAccount>(builder: FlowBuilder<TForms, TKinds, TCtx>) {
return builder.createRecurrentStepper('devices', { state: (): Device[] => [], build: (loop) => loop /* … */ .dock(/* … */) });
}
new StepperBuilder().withState({ apiUrl, signedIn }).use(withDevices).createStep(/* … */).build();
// ▲ a root, an arm or a ring alike: `use` keeps the caller's kindA fragment returns the plain chain, so apply it through use(...): the result is a
StepperBuilder on a root or an arm (build, fork still there), a
RecurrentStepperBuilder inside a ring (fold, dock still there), and its pool and
entries are the fragment's. A chain that does not meet the requirement is refused where
the fragment is applied. The type behind this is Rebound<this, …>: every chain method
hands back the kind it was called on.
Theming with adapters
Rendering splits into three orthogonal axes, each swapped once through the provider. Nothing else changes; you change the adapter.
- Control adapter — maps a field's kind (
'text','select', …) to a nativeFormValueControlcomponent. How one field looks. - Layout adapter — maps a layout spec to classes/styles on the container and each item. How a cluster of fields is arranged.
- Stepper chrome — a component that draws the wizard shell (nav, progress,
headings) around the headless
StepperController. How the wizard looks.
The core (ngx-formad/core, which the package root ngx-formad re-exports) carries no
controls and no chrome, so an application pays only for what it imports: the plain-HTML
formFields live in ngx-formad/template, the bundled
chrome in ngx-formad/chrome, the Tailwind layout in ngx-formad/tailwind and
Material in ngx-formad/material. Only the layout has a zero-dependency default
(cssLayout, inline CSS grid). Without a control adapter the first formField throws a
message naming the fix; without a chrome, the first stepper does. Mix the axes freely —
a Material control adapter sits happily inside a Tailwind layout under a custom chrome,
because they are orthogonal:
import { provideFormadSuite } from 'ngx-formad/core';
import { DefaultStepperChrome } from 'ngx-formad/chrome';
import { defaultControls } from 'ngx-formad/template';
import { tailwindLayout } from 'ngx-formad/tailwind';
bootstrapApplication(App, {
providers: [
provideFormadSuite({
controls: defaultControls, // or materialControls, or an adapter of your own
layout: tailwindLayout, // optional — omit to keep the CSS-grid layout
stepper: DefaultStepperChrome, // or a chrome of your own
}),
],
});Writing a control adapter
A control is any component implementing Angular's native FormValueControl<T>. The
renderer creates it with createComponent and the native FormField directive bound to
the field, so value, errors, touched, dirty, disabled and required all sync from the
field tree automatically — you only render them. Presentational extras (label,
placeholder, input type, select options, textarea rows) arrive as plain inputs
from the node; a prop authored as a signal is read live.
import { Component, input, model } from '@angular/core';
import { FormValueControl, ValidationError } from '@angular/forms/signals';
@Component({
selector: 'app-text',
template: `
<label class="my-field">
<span>{{ label() }}</span>
<input
[type]="type()"
[value]="value()"
[placeholder]="placeholder()"
[disabled]="disabled()"
(input)="value.set($any($event.target).value)"
(blur)="touched.set(true)"
/>
@if (touched() && errors().length) {
<small class="my-error">{{ errors()[0]?.message }}</small>
}
</label>
`,
})
export class AppText implements FormValueControl<string> {
value = model(''); // the contract: a two-way value the FormField directive binds
touched = model(false); // field state mirrored in from the tree — you read it
disabled = input(false);
required = input(false);
errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
label = input(''); // presentational inputs fed from the node, per kind
placeholder = input('');
type = input<'text' | 'email' | 'password' | 'tel' | 'url'>('text');
}The adapter is a registry from kind to component:
import { ControlAdapter } from 'ngx-formad/core';
export const myControls: ControlAdapter = {
controlFor: (kind) => {
switch (kind) {
case 'text':
case 'email':
case 'password':
case 'tel':
case 'url':
return AppText; // the node's `type` input picks the native input type
case 'textarea':
return AppTextarea;
case 'number':
return AppNumber;
case 'select':
case 'radio':
return AppSelect; // node feeds `options: { value, label }[]`
case 'checkbox':
return AppCheckbox;
default:
return null; // unknown kind → renderer falls back to its text control
}
},
};Returning null instead of throwing is the contract: a typo degrades to a text
input, and a kind only your adapter knows coexists with the defaults. The defaults
handle text, email, password, tel, url, textarea, number, select,
radio, checkbox, date, and file. The checkbox control also takes an
innerHTML input (from the node) for a rich label — a consent line with links, say.
Reserved input names. The native
FormValueControlcontract ownsvalueandchecked, the state namestouched,errors,disabled,disabledReasons,readonly,hidden,invalid,pending,dirty,nameandrequired, and the validation-metadata namesmin,minLength,max,maxLengthandpattern— all typed by the contract (min/maxarenumber | undefined,patternareadonly RegExp[]).FormFieldbinds them itself, and a field binding is refused on any of them, so a presentational input must not reuse one — which is why the bundleddatecontrol names its boundsminDate/maxDate.
Writing a layout adapter
A layout adapter turns a declarative spec into a class string, a style map, or
both — for the container and for each item. It never sees a field's kind;
that is the control axis.
import { LayoutAdapter } from 'ngx-formad/core';
// A hand-written one, for the shape of it; `tailwindLayout` ships this axis already,
// and resolves every breakpoint of a responsive value rather than a constant.
export const myGridLayout: LayoutAdapter = {
container: ({ cols = 1, gap = 4 }) => ({ class: `grid grid-cols-${cols} gap-${gap}` }),
item: ({ colSpan }) => (colSpan ? { class: `col-span-${colSpan}` } : {}),
};The three types: GridLayout = { cols?: Responsive<number>; gap?: number } (gap
in 0.25rem steps, Tailwind's scale); GridPlacement = { colSpan?: Responsive<number>;
colStart?: Responsive<number> } (per field, from formLayout().field(key, placement)
or a control()'s placement); both methods return LayoutClasses = { class?:
string; style?: Record<string, string> }. A Responsive<number> is a constant or a
per-breakpoint map; responsiveEntries(value) unfolds either into [breakpoint,
value] pairs, base first, which is what the two bundled adapters do. So one spec
resolves differently per adapter — your form config never changes:
| Adapter | What the container element gets |
| --- | --- |
| default (cssLayout) | style="display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem" |
| tailwindLayout | class="grid grid-cols-2 gap-4" |
Tailwind gotcha: classes built by interpolation (
grid-cols-${cols}) must be discoverable by Tailwind's compiler — safelist them, or map specs to static names.
Angular Material
A ready-made Material control adapter ships as a secondary entry point —
ngx-formad/material — so you don't write one. It's part of the ngx-formad
package (no separate install, no version skew), with @angular/material and
@angular/cdk as optional peer dependencies pulled in only when you import it.
pnpm add @angular/material @angular/cdk # if you don't already have themimport { provideFormadSuite } from 'ngx-formad/core';
import { materialControls } from 'ngx-formad/material';
bootstrapApplication(App, {
providers: [provideFormadSuite({ controls: materialControls })],
});Every form then renders with mat-form-field / mat-select / mat-checkbox /
mat-datepicker (and a Material file picker), no change to any form config. It
implements the same ControlAdapter contract as the default, mapping the same kinds
(including date and file); errors bridge to Material's <mat-error> via a
per-control ErrorStateMatcher reading the field's touched/errors signals. The
date control needs a DateAdapter in the app (provideNativeDateAdapter() or a
Moment/Luxon adapter), as any Material datepicker does. Swapping the layout or
stepper-chrome axis is independent, as always.
mat-form-field is inline-block, so a field renders at its content width rather
than filling its grid column. The adapter ships a small stylesheet for this —
@use it once from your global styles (it's plain CSS you can override, not a theme):
@use 'ngx-formad/material/styles';Writing a stepper chrome
The wizard shell is split in two: a headless StepperController owns all the
orchestration (the effective sequence, navigation, the completion gate, the
directional sync, lazy and deferred building), and a chrome component draws the
look around it. To restyle the wizard you write a chrome — a component whose only
input is the controller — and register it; you never reimplement the engine.
import { Component, input } from '@angular/core';
import { StepPageView, StepperController } from 'ngx-formad/core';
@Component({
selector: 'app-chrome',
imports: [StepPageView],
template: `
<header>{{ c().heading() }} — step {{ c().index() + 1 }} of {{ c().total() }}</header>
@if (c().loading()) {
<div class="skeleton"></div>
} @else if (c().step(); as step) {
<ngx-formad-step-page [step]="step" /> <!-- renders the step's whole page -->
}
@if (c().nextBlocked()) {
<aside role="alert"> <!-- why Next refused: labeled, human-readable -->
@for (issue of c().blockers(); track $index) {
<p>{{ issue.label }}: {{ issue.message }}</p>
}
</aside>
}
<footer>
<button [disabled]="!c().canBack()" (click)="c().back()">Back</button>
@if (c().optional()) {
<button (click)="c().skip()">Skip</button>
}
<!-- keep Next clickable: a blocked click *explains* instead of advancing -->
<button [attr.aria-disabled]="!c().canNext()" (click)="c().next()">
{{ c().isLast() ? 'Finish' : 'Next' }}
</button>
</footer>
`,
})
export class AppChrome {
readonly controller = input.required<StepperController>();
protected readonly c = this.controller;
}provideFormadSuite({ stepper: AppChrome });StepperController exposes signals - step, loading, index, total, label
and labels (the current step's short name, and every visible step's, in sequence
order - the progress rail; both derive from the same condition-filtered sequence as
index/total, so a gated step leaves the rail and the count together), heading,
subheading, validating (a validator on this step is still running - the gate is
closed and no field has an error to show), canBack, canNext, nextBlocked + blockers (why a
refused Next refused: every visible invalid field's labeled errors, plus the step's
incompleteMessage when completeWhen is what holds — see the blocked-Next
explainer), isLast, optional, deferTrigger,
result, committed (every last-committed model, keyed by step and then by pool
name — the data-egress for persisting as you go) and progress (the nesting-aware
rail model, one level per flow along the active chain) — and the actions next(),
back(), skip(), goTo(target, options?) (jump to a step by position or by label),
reset() (restart: clears committed values, resets shared state, re-enters the first
step), dockRestart() (reopen the innermost ring standing on its dock),
dockAmend(update) (rewrite that ring's state from its dock) and
destroy() (tear down the flow's injection environment; the shell calls it). Calling
next() while the gate is closed does not navigate: it marks everything touched and
raises nextBlocked, so keep the button clickable (aria-disabled, not disabled)
and let it answer.
A flow that repeats — recurrent steppers
A step list is a sequence, and some flows are rings: "add another device" runs the
same steps again and lands back on a list. Before 0.10 that loop had to be spelled as
a jump; now it is its own abstraction, equal in rank to the linear stepper. A
recurrent stepper is pass steps around a dock: finishing a pass folds its values
into the flow's own state (a pure (state, pass) => state over the accumulator
state: () => C declares), the dock can only reopen the ring or complete the flow, and entry
is conditional — a state for which dockReachable holds lands on the dock, any other
opens a pass on its first step (without dockReachable, the dock is reachable once a
pass has folded). Validity derives from the state (completeWhen), never from the
path taken to it.
new StepperBuilder()
.withState({ source: '' })
.createRecurrentStepper('devices', {
label: 'Devices',
state: (): Device[] => [],
dockReachable: (devices) => devices.length > 0,
build: (loop) =>
loop
.createForm('details', detailsForm)
.createStep((s) => s.createPage((p) => p.addForm('details')), { name: 'details' })
// the pass context: the pass's own named steps, live — fresh every pass
.fold((devices, pass) => [...devices, toDevice(pass.steps.details?.details.model())])
// the dock reads the folded state through the bindings callback, and its own
// completeWhen IS the flow's contract - there is no way out of a ring but the dock
.dock(
(s) =>
s.createPage(
(p) => p.addComponent(DeviceList, { bindings: (ctx) => [inputBinding('devices', () => ctx.flow.state())] }),
{ heading: 'Your devices' },
),
{ completeWhen: (ctx) => ctx.flow.state().length > 0, incompleteMessage: 'Add at least one device' },
),
})The flow occupies ONE slot of the parent sequence — one entry in the rail, one
index() position — and renders through the same single chrome, however deep. Later
steps read the state as ctx.steps.devices, typed to what state declared. A
nested flow has its own namespace of step names (the same name may recur on another
level) and reaches the level above explicitly through ctx.parent; its ctx.shared
layers its own withState over the parent's; inside a ring ctx.flow exposes the
state and passOpen signals.
A dock component stays dumb: it injects FlowDock<C> — restart() reopens the ring,
amend((state) => state) rewrites what it has collected without a pass (drop an
entry, mark one for editing before restart(); the dock's completeWhen and
dockReachable read the new state), next() moves on — or marks its buttons with
ngxFormadDockRestart / ngxFormadDockNext — never a controller. The
nesting also runs the other way (createStepper puts a linear sub-flow inside a
pass), and a ring built at the root (new RecurrentStepperBuilder({ state }), ending
in dock(...).build()) goes straight into <ngx-formad-stepper>; its completed
carries the state. A chrome that wants to draw the nesting reads
controller.progress() — one level per controller along the active chain; a chrome
that ignores it keeps the classic root rail.
goTo(target, { fresh }) remains the right tool for an "Edit" link on a summary —
not for repetition. It commits the current step first, exactly as Next and Back do,
and a target no step answers to leaves the wizard where it stands. fresh discards
the destination's cached instances and those of every step authored after it, so
they are built again from their form factories, seeded from whatever shared state
now holds.
StepPageView (ngx-formad-step-page) renders a step's whole page — forms,
dynamic components, rows, and content slots; wrap it in your own @defer to honour
deferTrigger(). (StepFormsView / ngx-formad-step-forms is the forms-only
subset, for a chrome that never uses addComponent.) The controller is also
exported, so a fully bespoke host can construct and drive it directly without the
token.
Performance
A few properties keep the orchestrator off the critical path of typing and clicking:
- Nothing is built ahead of time.
build()is data-only; the controller materializes the current step (plus the eager, ungated steps before it, so the context can name them), caches each by node identity and keeps it on revisit unless the author'srevisitpolicy says otherwise. Later steps don't exist until reached — proven by a test that counts model-factory calls. - Typing touches what reads it. A gate reads what it names —
ctx.steps.basics.basics.model()is the live model — so a keystroke re-evaluates the gates that read that form and nothing else. The field list is static, and a field's validity is its own native signal. - Navigation is one write. The controller's position is an immutable value in a
single signal; a move replaces it, and the current step, the rail, the gate and the
blockers are computeds over it. An eager step is materialized in the same action, so
it is on screen with the next change detection; a step with lazy forms shows
loading()until its configs resolve. Cached steps are instant. - Heavy steps stay off the critical path via lazy form configs (
createForm(name, () => import(...))) and per-stepdefer.
Pair provideFormadSuite() with provideZonelessChangeDetection() — signal forms
are zoneless, and the whole suite is signal-driven, so change detection stays
fine-grained.
Custom control kinds
The built-in kinds (text, select, date, file, …) cover ordinary fields. When
a field is a widget — a product picker, a company-lookup field, a colour swatch —
register it as a custom control kind and use it from config like any built-in. A
custom control is just a FormValueControl<Value> plus one typed props input; the
renderer hands it props and binds value/validity through the native FormField.
// 1 — the control: value + a typed props bag (a label, if it wants one, lives in props)
@Component({ selector: 'app-product-picker', template: `…` })
class ProductPicker implements FormValueControl<Product[]> {
value = model<Product[]>([]);
props = input.required<{ max?: number }>(); // ← the only extra input
}
// 2 — the kind, declared to formodel once, and a typed helper used like text()/select()
declare module 'formodel' {
interface CustomControlNodes {
productPicker: CustomControlNode<Product[], { max?: number }>;
}
}
const productPicker = customInputType<CustomControlNode<Product[], { max?: number }>>('productPicker');