Skip to main content

Command Palette

Search for a command to run...

Angular Nested Signal Forms

Angular 21 Signal Forms with Nested Forms: A Practical Demo

Published
7 min readView as Markdown
Angular Nested Signal Forms

Angular 21’s Signal Forms promise a more signal-native way to build forms: type-safe field trees, schema validation, and fine-grained reactivity. The question is: how do you scale that to real apps where forms are split across nested components?

In this post I’ll walk through my open-source demo repo Angular-Nested-Signal-Forms, which focuses on exactly that: a parent “registration” form split into reusable sub-forms and shared input components.

What this repo demonstrates

The repository is intentionally small, but it hits the practical stuff:

  • A typed model signal as the single source of truth (user registration shape)

  • A form(model, schema => …) setup with built-in validators plus a custom validator for signal forms

  • Nested components that receive a slice of the form (a FieldTree) via input.required()

  • Reusable form items + a generic error component that reads field state signals (touched, invalid, errors)

  • Nullable/undefined initialization of number fields.

The data model: keep it boring and typed

The form model is a signal holding a typed object with nested objects for details and address. That shape is your contract: it drives the field tree and the nested components. It’s the single source of truth. You create it from a signal that represents the shape of your form data. For this example, I’ve used a user model with some basic user information and grouped some nested details to pass down to the nested form components:

// Declare the types to use
export interface UserData {
  id: number;
  email: string;
  username: string;
  password: string;
  details: UserDetails;
  address: UserAddress;
}

export interface UserDetails {
  firstName: string;
  lastName: string;
  phone: string;
}

export interface UserAddress {
  street: string;
  number: number;
  city: string;
  // etc.
}

// We create the typed signal with the user model
userModel = signal<UserData>({ 
    // Default values required
    id: NaN, // undefined/null not possible yet, see further down the blog
    email: '',
    details: {
        firstName: ''
        // etc.
    },
    // etc.
});

// Then we add the signal to the signal form and add validation
userForm = form<UserData>(this.userModel, (schema) => {
  required(schema.id);
  email(schema.email);
  required(schema.details.firstName);
});

Using fields directly: a reusable TextFormInputComponent

With Signal Forms, userForm.email, userForm.details.firstName, etc. aren’t “controls” in the classic sense, they’re typed Field<T> objects. That makes it easy to build reusable components that accept a field and render:

  • the <input>

  • the label

  • touched/invalid state

  • validation errors

Example usage in a form template

<!-- In our form component we pass down the field -->
<app-text-form-item label="Email" [field]="userForm.email" />

In the reusable text-form-item component, we get the required field as an input signal:

export class TextFormItemComponent extends FormItemComponent {
  field = input.required<FieldTree<string>>();
  label = input.required<string>();
}

Then we can use it in the template both for binding the field to the input, as well as reading its state and errors.

<div class="form-item">
    <label [for]="label()">{{ label() }}</label>
    <input [id]="label()" type="text" [placeholder]="label()" [field]="field()" />
    @let fieldState = field();
    <div class="form-item-errors">
        @if (fieldState().touched() && fieldState().invalid()) {
            @for (error of fieldState().errors(); track error) {
                <p>{{error.message}}</p>
            }
        }
    </div>
</div>

This is a simplified version of the actual open-source project code. Please feel free to dive deeper into the source code at https://github.com/sembo199/Angular-Nested-Signal-Forms.

Passing down a nested part of the form

Once your form model is structured, passing a nested part of the form to a child component becomes straightforward. Instead of creating a new form or rebuilding controls, the parent component hands down a slice of the existing field tree. In this example, the parent passes userForm.details directly to the DetailsFormComponent. That value is a FieldTree<UserDetails>, not raw data, meaning it already contains values, validation state, and signal-based reactivity. The child component declares this dependency using input.required<FieldTree<UserDetails>>(), making the contract explicit and type-safe. Inside the nested template, individual fields like firstName, lastName, and phone are accessed via detailsForm() and passed straight into reusable form items. The key point is that no new form state is created. Both parent and child operate on the same underlying form model, just scoped to different parts of the tree.

Parent component

<app-details-form [detailsForm]="userForm.details" />

Nested form component

import { input } from '@angular/core';
import type { FieldTree } from '@angular/forms/signals';

export class DetailsFormComponent {
  detailsForm = input.required<FieldTree<UserDetails>>();
}

Nested form template

<div id="details-form" class="subform">
  <h3>Details Form</h3>
  <app-text-form-item label="First Name" [field]="detailsForm().firstName" />
  <app-text-form-item label="Last Name" [field]="detailsForm().lastName" />
  <app-text-form-item label="Phone" type="tel" [field]="detailsForm().phone" />
</div>

Why this is different from FormGroup nesting

With classic Reactive Forms, nested components often participate in building the form by creating or mutating FormGroup instances. That blurs responsibilities and makes “dumb” form components hard to achieve.

With Signal Forms, the entire form structure is defined up front from the model signal. When a parent passes userForm.details to a child, it passes a typed slice of an existing field tree, not a mutable group. This fits naturally with a dumb component architecture: the parent owns the form and its structure, while child components are purely presentational. They receive fields, bind inputs and surface validation state.

Other stuff worth mentioning

Nullable number fields don’t compose well (yet)

One rough edge I ran into is how numeric fields are handled. A Field<number> expects an actual number, which means you can’t represent an “empty” state cleanly. Widening the model to number | null sounds like a solution, but it quickly breaks down once you introduce reusable input components. Those components accept Field<string | number>, matching how HTML inputs work. As soon as your field becomes Field<number | null>, it no longer matches that contract, and you can’t pass it through without adapters or type assertions. In practice, this leads to using NaN as an initial value not because it’s elegant, but because it keeps the field compatible with input bindings and reusable form components. You might have noticed this in my default values for the user data.

Custom validators benefit directly from signals

Signal Forms also make it surprisingly easy to write custom validators that depend on external state. Validators receive access to the field’s value as a signal, which means they can safely read other signals without subscriptions or manual revalidation. In this demo, I added a validator that checks whether a user ID is unique by comparing it against a signal holding existing users:

userForm = form<UserData>(this.userModel, (schema) => {
    // .. validators ...
    validate(schema.id, ({ value }) => {
      let isDuplicate = false;
      this.users().forEach(user => {
        if (user.id === value()) {
          isDuplicate = true;
        }
      });
      if (isDuplicate) {
        return {
          kind: 'unique',
          message: 'ID must be unique',
        };
      }
      return null;
    });
    // .. validators ...
});

The key advantage here is that both value() and this.users() are signals. The validator automatically re-runs when either the field value changes or the users list changes. There’s no need for async validators, subscriptions, or manual calls to revalidate. The form stays consistent by construction.

Field vs FieldTree

In Signal Forms, FieldTree<T> is not just for nested objects, it’s a structurally typed wrapper around Field<T>. A leaf field like userForm.email is still typed as FieldTree<string> because string has no nested keys, so the type effectively collapses to a plain Field<string>. This is why passing userForm.email into an input expecting FieldTree<string> works without issue. Importantly, you should always pass the field reference (userForm.email), not its dereferenced value (userForm.email()), as calling it would strip away reactivity and validation state. Using FieldTree<T> in reusable components is therefore intentional and future-proof: it accepts both leaf fields and nested form slices while preserving the full signal-based form behavior.

Conclusion

Signal Forms feel like a genuine step forward for complex form scenarios in Angular, especially when you start splitting forms across nested components. The combination of a typed model, field trees, and signal-based reactivity makes it much easier to keep form structure, validation, and UI responsibilities cleanly separated.

That said, this is still an experimental API, and it shows in a few places. Numeric fields lacking a clean “empty” state and some rough edges around input typing are things you need to consciously design around. None of these are dealbreakers, but they’re worth knowing before you commit to this pattern in a large codebase.

Overall, if you care about dumb component architecture, strong typing, and predictable reactivity, Signal Forms already offer a better mental model than classic Reactive Forms even in their current state. For new projects or greenfield form-heavy features, they’re absolutely worth exploring.

Feel free to contribute to the open-source project on GitHub: https://github.com/sembo199/Angular-Nested-Signal-Forms.