Angular 22 landed in June, and the headline in it for me is that Signal Forms are stable. They were experimental in v21, they are not any more, and they ship with Angular Material and Angular Aria support.
They are worth your attention for 3 things:
-
The model is a plain signal you own, so the data and the form are never two copies of the same thing drifting apart. Field access is typed off that model:
form.titlereplaces formerform.get('title'), and a typo becomes a compile error instead of anundefinedat runtime. -
Validation lives in a schema, next to the shape it describes, with the error copy on the rule instead of endless template
@ifchains. -
Every piece of form state is a signal, from value and errors to touched, dirty, pending and submitting, so nothing needs subscribing to and ordinary inputs need no
ControlValueAccessoranymore.
So it's time to try them out. The form we'll convert belongs to a small task board which features three columns and a form to add a task, backed by a mock API.
The first article put the board's state in a method-based NgRx Signal Store, and the second moved that store to the events plugin, where a component dispatches an event and an effect answers with another one.
The form itself was still built with our good old friend FormBuilder, and in this post we move it to Signal Forms. Most of that is straightforward: fb.group becomes form(), Validators become schema rules, formControlName becomes [formField].
Submitting is the part that isn't, because this form submits to an event-driven NgRx Signal Store, and that's where most of this post goes. Full code is in the repository linked at the end.
The Model Is a Signal You Own
Declaring the Form
A Signal Form starts from data, not from controls. Our task form needs a title, a description and the column the task starts in, so that will be our model: a plain object in a signal, handed to form() together with the rules that define it. Only the title has rules, since it is required and needs three characters:
const EMPTY_DRAFT: NewTask = {
title: '',
description: '',
status: TaskStatus.TODO,
};
readonly draft = signal({ ...EMPTY_DRAFT });
readonly taskForm = form(this.draft, path => {
required(path.title, { message: 'Title is required' });
minLength(path.title, 3, { message: 'Title must be at least 3 characters' });
});
With FormBuilder the values used to live inside the FormGroup, and we were only able to reach them through the form. Now they live in a signal we declare ourselves, draft, and form() builds the fields on top of it without taking a copy.
this.draft stays a normal WritableSignal that the rest of our component can read and write. Typing in the bound input writes straight back into it, and setting it updates the input, so there's no patchValue, no form.value, and no moment where the two disagree.
💡 This is the part that changes how a form feels. The model is the source of truth and the form is just a view over it, rather than the form owning a copy that we have to keep syncing.
In the repository the two rules live in one reusable schema() that the form applies with apply(path.title, taskTitleSchema), so a second form, like the board's edit form in the next post, can share it. The inline version above is the same thing, spelled out.
Typed Field Access
taskForm is a field tree shaped like our model. taskForm.title exists because title is on the model, and it's typed as a string field, so if we rename the property the template will stop compiling, which is the part get('title') could never do for us.
Nested models nest too. A { address: { city: string } } model gives us taskForm.address.city, typed all the way down, and applyEach applies a schema to every item of an array field.
Reading Field State
Calling a field, as in taskForm.title(), gives us its state, and every member of that state is a signal:
taskForm.title().value(); // 'Clean the kitchen'
taskForm.title().errors(); // [{ kind: 'minLength', message: '…' }]
taskForm.title().touched(); // false until the user has been near it
taskForm.title().valid(); // and invalid(), dirty(), pending(), required()
taskForm().submitting(); // on the root, while a submit is in flight
value is a writable signal, so taskForm.title().value.set('…') works whenever we need to update a value directly.
💡 Errors are computed whether or not the user has touched the field. An empty required field is invalid from the moment the form exists.
touched()is what decides whether we show anything, which matters later when we reset.
Validation in a Schema
The second argument to form() is a schema function. It receives a path tree mirroring our model, and we attach rules to paths, so a rule sits next to the shape it describes rather than in an array on a control constructor.
Built-in Validators
The built-in validators cover the common use cases:
-
required,email,pattern -
minandmaxfor numbers,minDateandmaxDatefor dates -
minLengthandmaxLengthfor strings and collections
Each one takes an options object where message is the string our template renders, so the error copy lives with the rule instead of in an @if chain keyed on errors?.['minlength'].
Custom and Async Rules
For any use case the built-in validators don't cover, there is validate, returning an error object or null:
validate(path.title, ({ value }) =>
value().trim().toLowerCase() === 'untitled'
? { kind: 'placeholder', message: 'Give it a real name' }
: null
);
Async rules get validateAsync and validateHttp, and the field's pending() signal returns true while they run, so a spinner on a username check is a single binding rather than component state we'd have to maintain by hand.
Reusing a Zod Schema
The one I'd use on a bigger form is validateStandardSchema. Signal Forms accept anything implementing Standard Schema, so a Zod or Valibot schema we already maintain for our API types can validate the form too, instead of being restated as form rules:
form(this.draft, path => validateStandardSchema(path, TaskDraftSchema));
Our task board is small enough that the built-ins are the right call, so that stays a pointer rather than something this post demonstrates.
Conditional Rules
Validators aren't the only thing the schema function holds. The same path tree carries conditional logic:
-
disabled,readonlyandhiddentake a predicate over the model -
applyWhenscopes a whole block of rules to a condition -
debounceholds a field's input back from the model, and so from validation, until the user pauses or blurs
So "required only when a checkbox is on" stops being a subscription in ngOnInit.
Binding Fields in the Template
The FormField Directive
One single directive connects a field to a control. Here is the title input of our task form, its error block and the submit button:
<input type="text" [formField]="taskForm.title" class="form-control" />
@if (taskForm.title().touched() && taskForm.title().invalid()) {
<div class="error-message">
@for (error of taskForm.title().errors(); track error.kind) {
<span>{{ error.message }}</span>
}
</div>
}
<button type="submit" [disabled]="taskForm().invalid() || taskForm().submitting()">
@if (taskForm().submitting()) { Adding... } @else { Add Task }
</button>
And here is how it works:
-
[formField]="taskForm.title"is the whole binding. NoformControlName, and noControlValueAccessorfor a standard input. - The error block reads
touched()andinvalid()together, so nothing shows until the user has been in the field, and then every entry inerrors()renders with the message from its rule. - The button's disabled state reads
invalid()andsubmitting()off the root field, instead of a component boolean we'd have to keep in sync.
Custom Controls Without a ControlValueAccessor
[formField] binds a plain <input> with no extra code. A custom component of our own is a different story, and our form has one: the status in the model is picked with 3 buttons, one per column, since until now every new task had to start in To Do.
With reactive forms that picker would implement ControlValueAccessor. With Signal Forms it implements FormValueControl<T> instead, and that contract is much smaller: the only required member is a model() called value.
@Component({ selector: 'app-status-picker', /* … */ })
export class StatusPickerComponent implements FormValueControl<TaskStatus> {
readonly value = model(TaskStatus.TODO);
readonly disabled = input(false);
readonly touch = output<void>();
readonly options = [
{ status: TaskStatus.TODO, label: 'To Do' },
{ status: TaskStatus.IN_PROGRESS, label: 'In Progress' },
{ status: TaskStatus.DONE, label: 'Done' },
];
}
The template is a row of buttons that reads and writes value:
<div role="group" aria-label="Starting column" (focusout)="touch.emit()">
@for (option of options; track option.status) {
<button
type="button"
[attr.aria-pressed]="value() === option.status"
[disabled]="disabled()"
(click)="value.set(option.status)">
{{ option.label }}
</button>
}
</div>
And the task form binds it exactly like the input above:
<app-status-picker [formField]="taskForm.status" />
What each member does:
-
valueis two-way bound totaskForm.status. A click callsvalue.set(), which writes intodraft, and areset()on the form moves the pressed button back to To Do. -
disabledis optional. Because we declared it, adisabledrule on the status field greys the buttons out with no extra binding. -
touchis optional too. Emitting it marks the field touched, and ours emits when focus leaves the group, the same moment a native input would.
Everything beyond value is optional. Anything we do declare is kept in sync with the field's state for us: errors, disabled, readonly, hidden, invalid, pending, touched, dirty, required, and the constraint values min, max, minLength, maxLength and pattern. Checkboxes implement FormCheckboxControl instead, which swaps value for checked.
💡 This is the part to check thoroughly before migrating a design system. A component library only works with Signal Forms once it implements this contract, and a
ControlValueAccessorwritten for reactive forms does not carry over.
What Replaces ngSubmit
One thing to know when we drop ReactiveFormsModule: (ngSubmit) goes with it, so we have 2 options.
- Handle
(submit)ourselves and callpreventDefault() - Pass
submissionoptions intoform()and use<form [formRoot]="taskForm">, which hostsnovalidateand callspreventDefault()for us
The examples below take the first route, since it keeps the submit logic in the component where it's easier to read.
The Form This Replaced
For contrast, here is what our task board had, and what the 2 previous articles have shown:
taskForm: FormGroup = this.fb.group({
title: ['', [Validators.required, Validators.minLength(3)]],
description: [''],
});
createTask() {
if (this.taskForm.invalid) return;
this.dispatch.taskCreated({
title: this.taskForm.get('title')?.value,
description: this.taskForm.get('description')?.value,
status: TaskStatus.TODO,
});
this.taskForm.reset();
}
It works, but it has no idea whether the task was created. dispatch returns nothing, so the method ends, the form clears, and a failed API call reaches the user as a line in the console.
Signal Forms have an answer for that, and it's where the conversion stopped being a straight swap.
Submitting, and What It Expects of You
The submit() Contract
submit() takes the form and an action it can await. Errors the action returns get routed onto the fields we name:
await submit(this.taskForm, async f => {
const result = await saveTask(f().value());
if (result.ok) return;
return { kind: 'taken', message: 'That title is taken', fieldTree: f.title };
});
It does 3 more things for us:
- Marks the whole tree touched, so every outstanding error becomes visible at once
- Refuses a second submit while one is in flight
- Drives
submitting()throughout, which is what the button binding above reads
For a component that calls an HTTP service directly, that's the whole story, and it's a real improvement on wiring those states by hand.
Where an Event-Driven Store Breaks the Assumption
submit() assumes submitting is a function that eventually resolves.
In my previous article our task board moved to the NgRx Signal Store events plugin, where a component never calls a store method. It dispatches an event, a reducer updates state, and an effect performs the side effect and dispatches another event with the result.
That's where submit() gets stuck. dispatch.taskCreated(...) returns void, and the outcome turns up later, elsewhere, as taskCreatedSuccess or taskCreatedFailure. There's nothing to await.
When Binding Is Enough
Not every form needs to await, though. If the template binds to the store's loading and error state, the button can disable itself and an alert can show the failure, and the component never awaits anything. For a lot of forms that is enough, and submit() isn't needed.
Our form wants more than that. It wants submitting() to drive the button, and it wants a duplicate title to land under the title input instead of in an alert at the top of the board. Both of those come from submit(), so the form needs something it can await.
To my knowledge, neither Angular nor NgRx ships a helper that turns an event round trip into a promise. When someone asked for an rxjs-flavoured submit in angular/angular#65199, Angular declined, since the existing RxJS APIs already cover it. On the NgRx side, the answers so far point at the store:
- In ngrx/platform#5053, the recommendation for Signal Forms with a SignalStore is that the form calls a store method on submit
- In ngrx/platform#4980, which asks for a promise from
rxMethod, the maintainer's answer is a regular method that returns a promise -
Full-Cycle Reactivity in Angular shows that method being a mutation from
@ngrx-toolkit/core
That sounds like the opposite of how an event-driven store works, since components there dispatch events instead of calling methods. It doesn't have to be, as long as the method keeps one rule.
A Mutation That Speaks Events
The rule worth keeping is that the reducer is the only thing that changes tasks. A mutation can keep that rule, as long as it reports its outcome as the same API events the effect used to.
The Mutation
The taskCreated page event and the createTask$ effect that answered it in the previous article are gone. In their place our store gets a createTask mutation:
withMutations(store => ({
createTask: rxMutation<NewTask, Task>({
operation: (draft: NewTask) => store._taskService.createTask(draft),
onSuccess: task =>
store._dispatcher.dispatch(taskApiEvents.taskCreatedSuccess(task)),
onError: error =>
store._dispatcher.dispatch(
taskApiEvents.taskCreatedFailure(errorMessage(error))
),
}),
})),
Step by step:
-
operationcalls the service with the draft, status included, so the column picked in the form is the column the task lands in. -
onSuccessandonErrordispatchtaskCreatedSuccessandtaskCreatedFailure, so the existing reducer adds the task unchanged, and every result still shows up as an event. - Calling
store.createTask(draft)returns a promise of{ status: 'success' | 'error' | 'aborted' }, which is the thingsubmit()was missing.
errorMessage is a two-line helper that pulls the message out of whatever the service rejected with and falls back to String(error). Both the mutation and the component use it, so the text under the field is the text the server sent.
The Component
Our submit is now what submit() wanted all along:
async createTask(event: Event) {
event.preventDefault();
await submit(this.taskForm, async f => {
const result = await this.store.createTask(f().value());
if (result.status === 'error') {
return {
kind: 'server',
message: errorMessage(result.error),
fieldTree: f.title,
};
}
this.taskForm().reset({ ...EMPTY_DRAFT });
return undefined;
});
}
We await the mutation, turn an error into a message with fieldTree: f.title, and reset on success. A rejected create now lands on the title field as a message under the input, the same way a minLength failure does.
Resetting Without the Error Flashing Back
Writing an empty draft into this.draft clears the form, but "Title is required" appears the instant it does.
The field is still touched, and an empty touched title is required again.
So we use taskForm().reset(value) instead, which clears touched and dirty along with the value.
When I replaced reset() with a plain signal write, no test failed, so the suite now has one that checks touched() is cleared after a successful create.
The Rollback That Was Already There
The mutation only replaced the create path, so everything else our store did in the previous article still works, including the rollback when moving a card fails.
Moving a task between columns is an optimistic update. The reducer applies the change the moment the page event arrives:
on(taskPageEvents.taskStatusChanged, event =>
updateEntity(
{ id: event.payload.id, changes: { status: event.payload.status } },
{ collection: 'task' }
)
),
and reverts it when the API disagrees, using the previous status the component sent with the event, and says why in the board's alert:
on(taskApiEvents.taskStatusChangedFailure, event => [
updateEntity(
{ id: event.payload.id, changes: { status: event.payload.previousStatus } },
{ collection: 'task' }
),
{ error: event.payload.error },
]),
The mock fails every status change on task ID 3, so we can watch a card move and snap back. The form now reports its own failures the same way the board does, on a field instead of a column.
💡 Events still run the board. Loading, moving a card and deleting still dispatch events, because nothing awaits them. If a form needs the outcome, it calls a mutation; if nothing waits, dispatch an event.
Testing It
To get a failure only the server can know about, the mock now rejects a duplicate title. The test worth showing asserts the thing this post is about:
it('routes a server rejection onto the title field', async () => {
const existing = 'Clean the kitchen'; // seeded in HOUSEHOLD_TASKS
component.draft.set({ title: existing, description: '', status: TaskStatus.TODO });
await component.createTask(new Event('submit'));
const errors = component.taskForm.title().errors();
expect(errors.map(e => e.kind)).toContain('server');
expect(errors.map(e => e.message)).toContain('A task with this title already exists');
});
The test seeds the model straight through draft, submits the way the template would, and asserts on the title field's errors() rather than on anything the store exposes. It never touches the mutation directly, so it would pass unchanged against any other way of reaching the server, which is the point of testing through the form.
Rejecting duplicates broke an existing test, which created the same task twice to prove ids are unique, so it now creates 2 differently named tasks.
When to Reach for Signal Forms
Both form systems live in the same app. @angular/forms and @angular/forms/signals are separate entry points, so this is a per-form decision rather than a migration you have to finish.
Use Signal Forms when:
- You are on v22 and writing a new form, especially in a component that is already signal-based
- The form edits data you already have a type for, like a
Task, so a renamed or mistyped field fails at compile time instead of in the browser - You want a server error, like a duplicate title, to show under the field it belongs to without wiring that up by hand
- You already keep a Zod or Valibot schema for the same shape and would rather validate against it once
Stick with reactive forms when:
- You are below v22, where the API was still experimental and moved between releases
- You have a large body of existing forms, because there is no migration schematic and each one is a manual rewrite
- You depend on third-party form controls that have not implemented
FormValueControlyet - The form is a throwaway with one field and no validation, where either option is fine and the familiar one is cheaper
And if the form submits into an event-driven store, give the store a mutation that dispatches its existing events.
💡 I would not go back and convert working forms for the sake of it. The case for Signal Forms is strongest where a form has real validation and a real submit, because that is where the typed schema and the field-level error routing earn their keep. A 3-field contact form is not where you will notice the difference.
Resources
- Code for this article: View on GitHub (v4.1.0-signal-forms-custom-control)
- Signal Forms documentation
- NgRx Signal Store Events documentation
- ngrx/platform#5053: Using Signal Store and Signal Forms
- Full-Cycle Reactivity in Angular: Signal Forms, Signal Store, Resources, Mutation API
-
NgRx custom store features: Known TypeScript Issues, if
signalStore()fails withNo overload matches this callonce a feature declares the state it needs. The fix is an unused generic on the feature, and thesignalsconfig of@ngrx/eslint-pluginflags any feature missing it. - Previous article: Event-Driven State Management with NgRx Signal Store (the events plugin this post submits into)
- First article: Using NgRx Signal Store (the method-based store the board started with)
How are you handling submit in a signal-based app? If you've kept a store purely event-driven and found a cleaner way to await the outcome than a mutation, I would like to see it.
Top comments (0)