All articles
Form Builder
Stepper
Renderer

Form Builder steps, external steppers, and one-form rendering

Multi-step forms can mean two different things: a renderer-owned stepper configured by FormBuilderFlow, or a fully external ngs-stepper controlled by the host application. The difference matters because it decides whether the form is one Angular FormGroup or several independent forms.

July 1, 202610 min read

The problem

A large schema often starts as one flat form. Later it needs to become a guided flow: Client, Invoice, Review, Payment, or any other sequence that makes the form easier to complete. The first idea is usually to place a ngs-stepper around several renderers and render one part of the schema per step.

That visual structure is simple, but the form model is not automatic. If every step creates its own ngs-form-renderer, every renderer creates its own Angular FormGroup. The UI looks like one flow, but validation, calculations, dirty state, touched state, and submit values are split across multiple form instances.

Supported today: external flow config

The supported runtime-only option is to keep the stepper inside ngs-form-renderer, but pass the step layout from outside. This uses the flow input. The saved FormBuilderSchema can stay reusable and flat, while a specific screen decides how to split it into steps.

runtime-flow.ts
const runtimeFlow: FormBuilderFlow = {
  mode: 'steps',
  steps: [
    {
      id: 'client',
      title: 'Client',
      items: [
        { kind: 'section', id: 'client' },
      ],
    },
    {
      id: 'invoice',
      title: 'Invoice',
      items: [
        { kind: 'section', id: 'items' },
        { kind: 'field', id: 'invoice_total' },
      ],
    },
  ],
};
renderer-flow.html
<ngs-form-renderer
  [schema]="schema"
  [flow]="runtimeFlow"
  [(value)]="value"
  (formSubmit)="save($event)" />

In this mode the renderer still owns one form group. The stepper is internal, but the step setup is external. Calculated fields, plain text expressions, validators, repeaters, and submit all operate against one form value.

What is not one form

The following pattern is visually tempting, but it does not produce one form with the current renderer contract. Each renderer creates a separate form group because there is no shared form context.

separate-renderers.html
<ngs-stepper>
  <ngs-step label="Client">
    <ngs-form-renderer [schema]="clientSchema" />
  </ngs-step>

  <ngs-step label="Invoice">
    <ngs-form-renderer [schema]="invoiceSchema" />
  </ngs-step>
</ngs-stepper>
  • Each step has its own validators and validity state.
  • Cross-step calculations cannot safely depend on one shared form value.
  • Submit must manually merge values from several renderer instances.
  • Dirty and touched state are fragmented by step.

External stepper architecture

A true external stepper is a different API. The host owns the ngs-stepper, and the renderer becomes a partial schema renderer. It needs to accept the list of top-level items to render for that step.

external-stepper.html
<ngs-stepper>
  @for (step of steps; track step.id) {
    <ngs-step [label]="step.title">
      <ngs-form-renderer
        [schema]="schema"
        [items]="step.items"
        [showSubmit]="false" />
    </ngs-step>
  }
</ngs-stepper>

This solves visual ownership, but it is still not enough for one form. It decides which fields appear in each step. It does not decide where controls live.

External stepper as one form

To make an external stepper behave as one form, all partial renderers must share the same Angular FormGroup. The host form owns submit, while each renderer only projects a subset of fields into that shared form.

one-form-external-stepper.html
<form [formGroup]="form" (ngSubmit)="submit()">
  <ngs-stepper>
    @for (step of steps; track step.id) {
      <ngs-step [label]="step.title">
        <ngs-form-renderer
          [schema]="schema"
          [items]="step.items"
          [formGroup]="form"
          [showSubmit]="false" />
      </ngs-step>
    }
  </ngs-stepper>

  <button ngsButton="filled" type="submit">Submit</button>
</form>

That requires renderer inputs like items and formGroup. The renderer would use the supplied group instead of creating its own. The same controls, value object, validators, calculated fields, and submission lifecycle would then span every step.

renderer-contract.ts
readonly items = input<FormBuilderLayoutItem[] | null>(null);
readonly formGroup = input<FormGroup | null>(null);

protected readonly activeItems = computed(() =>
  this.items() ?? normalizedLayout(this.schema())
);

protected readonly form = computed(() =>
  this.formGroup() ?? this.createFormGroup()
);

Which model to choose

Use renderer-owned steps when you want the fastest safe implementation. Pass [flow] when the step layout is runtime-specific and should not be stored in the schema. Use a true external stepper only when the parent screen must control the stepper header, actions, route synchronization, analytics, or cross-step navigation.

decision.html
// Use this when the renderer owns navigation.
<ngs-form-renderer [schema]="schema" [flow]="flow" />

// Use this only when renderer supports shared formGroup + items.
<ngs-stepper>
  <ngs-step>
    <ngs-form-renderer [schema]="schema" [items]="items" [formGroup]="form" />
  </ngs-step>
</ngs-stepper>

Summary

  • schema.flow stores step layout with the schema.
  • <ngs-form-renderer [flow]="flow"> keeps steps outside the schema.
  • Both of those modes still let one renderer own one form.
  • Multiple renderer instances are multiple forms unless they share a FormGroup.
  • A true external stepper needs partial rendering with items plus shared formGroup.