DEV Community

Cover image for Angular Signal Forms with an Event-Driven NgRx Signal Store
Dimitris Kiriakakis
Dimitris Kiriakakis

Posted on

Angular Signal Forms with an Event-Driven NgRx Signal Store

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:

  1. 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.title replaces former form.get('title'), and a typo becomes a compile error instead of an undefined at runtime.
  2. Validation lives in a schema, next to the shape it describes, with the error copy on the rule instead of endless template @if chains.
  3. 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 ControlValueAccessor anymore.

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' });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  • min and max for numbers, minDate and maxDate for dates
  • minLength and maxLength for 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
);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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, readonly and hidden take a predicate over the model
  • applyWhen scopes a whole block of rules to a condition
  • debounce holds 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>
Enter fullscreen mode Exit fullscreen mode

And here is how it works:

  1. [formField]="taskForm.title" is the whole binding. No formControlName, and no ControlValueAccessor for a standard input.
  2. The error block reads touched() and invalid() together, so nothing shows until the user has been in the field, and then every entry in errors() renders with the message from its rule.
  3. The button's disabled state reads invalid() and submitting() 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' },
  ];
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

And the task form binds it exactly like the input above:

<app-status-picker [formField]="taskForm.status" />
Enter fullscreen mode Exit fullscreen mode

What each member does:

  1. value is two-way bound to taskForm.status. A click calls value.set(), which writes into draft, and a reset() on the form moves the pressed button back to To Do.
  2. disabled is optional. Because we declared it, a disabled rule on the status field greys the buttons out with no extra binding.
  3. touch is 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 ControlValueAccessor written 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 call preventDefault()
  • Pass submission options into form() and use <form [formRoot]="taskForm">, which hosts novalidate and calls preventDefault() 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();
}
Enter fullscreen mode Exit fullscreen mode

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 };
});
Enter fullscreen mode Exit fullscreen mode

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:

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))
      ),
  }),
})),
Enter fullscreen mode Exit fullscreen mode

Step by step:

  1. operation calls the service with the draft, status included, so the column picked in the form is the column the task lands in.
  2. onSuccess and onError dispatch taskCreatedSuccess and taskCreatedFailure, so the existing reducer adds the task unchanged, and every result still shows up as an event.
  3. Calling store.createTask(draft) returns a promise of { status: 'success' | 'error' | 'aborted' }, which is the thing submit() 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;
  });
}
Enter fullscreen mode Exit fullscreen mode

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' }
  )
),
Enter fullscreen mode Exit fullscreen mode

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 },
]),
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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 FormValueControl yet
  • 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

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)