Build a form

Install Signal Forms

Install @angular/forms at your app's exact @angular/core version, shown by npm ls @angular/core. It is not a framework dependency. A mismatch causes ERESOLVE because @angular/forms requires matching @angular/core and @angular/common peers:

npm install @angular/forms@<version>

Signal Forms binds directly to native control models: form() expects value, or checked for boolean controls. <text-input> and <switch> expose these names. @ng-native/components uses this contract rather than ControlValueAccessor, which it does not support and never will.

Define the form

form() from @angular/forms/signals takes a data signal and a schema that adds validators to field paths:

import { Component, signal } from '@angular/core';
import { FormField, form, minLength, required } from '@angular/forms/signals';
import { Switch, Text, TextInput, View } from '@ng-native/components';

@Component({
  selector: 'app-sign-up',
  imports: [FormField, TextInput, Switch, Text, View],
  template: `
    <view>
      <text-input placeholder="Name" [formField]="f.name" />
      @if (f.name().touched() && f.name().invalid()) {
        <text>{{ f.name().errors().length }} error(s)</text>
      }

      <view class="flex-row items-center gap-2">
        <switch [formField]="f.subscribed" />
        <text>Subscribe to updates</text>
      </view>
    </view>
  `,
})
export class SignUp {
  protected readonly data = signal({ name: '', subscribed: false });
  protected readonly f = form(this.data, (path) => {
    required(path.name);
    minLength(path.name, 3);
  });
}

data is the readable, writable state signal; f is its field tree, and [formField] binds a control to a path. FormField, form, required and minLength come from Angular's @angular/forms/signals.

Include FormField in the component's imports. Without it, [formField] compiles but has no consumer: the control stays empty and the form invalid. This build does not run the browser compiler's usual "Can't bind to 'formField'" check. Instead, development builds log the element and missing import. The same applies to ngModel and Reactive Forms directives.

Why no adapter class was needed

<text-input> exposes value as a model<string>(); <switch> exposes checked as a model<boolean>(). These names let FormField provide two-way binding, disabled/readonly state and touched-on-blur handling. See Text input for the mapping of disabled()/readonly() to editable and invalid/touched to data-invalid/data-touched for CSS.

Style invalid state

Style the data-* attributes published by FormField and the control, as with press-state selectors:

text-input {
  border-width: 1px;
  border-color: #3a3a42;
  border-radius: 8px;
  padding: 12px;
}
text-input[data-invalid][data-touched] {
  border-color: #ff6b6b;
}

Submit the form

submit() runs your action only for a valid form and reports the outcome. Add a button, API call and status display:

import { Component, signal } from '@angular/core';
import { FormField, form, minLength, required, submit } from '@angular/forms/signals';
import { Pressable, Switch, Text, TextInput, View } from '@ng-native/components';

/** Stands in for your own API call. Resolves false for a name that is already taken. */
async function createAccount(data: { name: string; subscribed: boolean }): Promise<boolean> {
  return data.name !== 'ada';
}

@Component({
  selector: 'app-sign-up',
  imports: [FormField, Pressable, TextInput, Switch, Text, View],
  template: `
    <view>
      <text-input placeholder="Name" [formField]="f.name" />
      @if (f.name().touched() && f.name().invalid()) {
        <text accessibilityRole="alert">{{ f.name().errors().length }} error(s)</text>
      }

      <view class="flex-row items-center gap-2">
        <switch [formField]="f.subscribed" />
        <text>Subscribe to updates</text>
      </view>

      <pressable accessibilityRole="button" [disabled]="f().submitting()" (press)="save()">
        <text>{{ f().submitting() ? 'Signing up…' : 'Sign up' }}</text>
      </pressable>

      @switch (status()) {
        @case ('success') {
          <text>You're signed up.</text>
        }
        @case ('error') {
          <text accessibilityRole="alert">Fix the errors above, then try again.</text>
        }
      }
    </view>
  `,
})
export class SignUp {
  protected readonly data = signal({ name: '', subscribed: false });
  protected readonly status = signal<'idle' | 'success' | 'error'>('idle');
  protected readonly f = form(this.data, (path) => {
    required(path.name);
    minLength(path.name, 3);
  });

  protected async save(): Promise<void> {
    await submit(this.f, {
      action: async () => {
        const created = await createAccount(this.data());
        if (!created) {
          return { kind: 'taken', message: 'That name is already taken.', fieldTree: this.f.name };
        }
        this.status.set('success');
        return;
      },
      onInvalid: () => this.status.set('error'),
    });
  }
}

submit() first marks every field touched, showing errors without waiting for blur. For an invalid field tree, it calls onInvalid and resolves without running action. Only valid forms reach createAccount, the example API call. f().submitting() stays true during action, disabling the button and changing its label through signal bindings.

action can return a validation error after validation passes. When createAccount returns false, the example names f.name as the error's fieldTree, so f.name().errors() exposes it like a client-side validation error.

Arrays

An order, a quote or an invoice is a form with an array of items. An array in the model is an array of fields in the form: @for runs over it, and each item goes to a component of its own as a FieldTree:

import { Component, input, signal } from '@angular/core';
import { FormField, applyEach, form, required, type FieldTree } from '@angular/forms/signals';
import { Pressable, Text, TextInput } from '@ng-native/components';

interface Line {
  name: string;
  cents: number;
}

@Component({
  selector: 'app-line',
  imports: [FormField, Text, TextInput],
  template: `
    <text-input accessibilityLabel="Line" [formField]="line().name" />
    @if (line().name().touched() && line().name().invalid()) {
      <text accessibilityRole="alert">Name is required</text>
    }
  `,
})
export class LineCard {
  readonly line = input.required<FieldTree<Line>>();
}

@Component({
  selector: 'app-order',
  imports: [LineCard, Pressable, Text],
  template: `
    @for (line of f.lines; track line) {
      <app-line [line]="line" />
    }
    <pressable accessibilityRole="button" (press)="add()"><text>Add a line</text></pressable>
  `,
})
export class Order {
  readonly order = signal<{ lines: Line[] }>({ lines: [{ name: '', cents: 0 }] });
  readonly f = form(this.order, (path) => applyEach(path.lines, (line) => required(line.name)));

  protected add(): void {
    this.f.lines().value.update((lines) => [...lines, { name: '', cents: 0 }]);
  }
}

Adding, removing and reordering are updates of the array's own value, f.lines().value.update(). An item's field keeps its identity through a move, so what was typed into it, its touched and its errors follow the item, and track line moves its card rather than building another. applyEach gives every item the same rules, and an item's error goes when the item does.

A control with a model of another name takes a two-way binding. [formField] binds a control whose model is value or checked. One whose model is called something else, a price field with a cents model, is bound to the field's own value instead:

<app-price [(cents)]="line().cents().value" />

The model is not deeply equal to the data it was given. Signal Forms keys each item of an array with a symbol, so expect(order()).toEqual({ lines: [...] }) fails on the key. toMatchObject compares the properties it is given and passes.

A long array costs more per keystroke. The form's model is one signal, so a keystroke asks every field of every item whether it changed, though only the one line is committed, and a <scroll-view> mounts a card for every item when the form opens. For hundreds of items, a <virtual-list> mounts only the rows on screen.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.