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.