skip to content

In Angular Signal Forms, what does submit() do for a registration form, and how does a server's 'username taken' error reach the username field?

level: middleimportance: should knowfreq 30%

answer

  1. touch everything first
  2. failed rules stop the action
  3. pending does not block by default
  4. return errors with a fieldTree
  5. cleared on the next edit

basics

~20 s

submit() marks fields touched, calls onInvalid instead if any rule failed, otherwise runs the action with submitting() true. Errors the action returns, optionally targeting a fieldTree, land in that field's errors() and clear when the user edits it.

solid answer

~40 s

`submit(form)` (or the `FormRoot` directive on `<form [formRoot]="registrationForm">`, which also sets `novalidate` and prevents the default submit) runs a fixed sequence: mark every interactive field touched so errors show; if any rule has **failed**, run `onInvalid` instead of the action; otherwise run the async `action` while `submitting()` is `true`. By default `ignoreValidators` is `'pending'`, so a still-running async check does not block; `'none'` makes it block and `'all'` skips validation. The action returns nothing for success or error objects; an error with `fieldTree: field.username` is routed to that field's `errors()`. Such submission errors clear when the user edits the field. `submit()` resolves to `true` only when the action returns no errors, and a second call while one is running returns `false` without running the action.

code

ts · 42 lines
ts
import {Component, inject, signal} from '@angular/core';
import {form, FormField, FormRoot, required} from '@angular/forms/signals';
import {RegistrationApi} from './registration-api';

@Component({
  selector: 'app-registration',
  imports: [FormField, FormRoot],
  template: `
    <form [formRoot]="registrationForm">
      <input [formField]="registrationForm.username" />
      <input type="email" [formField]="registrationForm.email" />
      <button type="submit" [disabled]="registrationForm().submitting()">
        @if (registrationForm().submitting()) { Creating account... } @else { Register }
      </button>
    </form>
  `,
})
export class Registration {
  private readonly api = inject(RegistrationApi);
  protected readonly model = signal({username: '', email: ''});

  protected readonly registrationForm = form(
    this.model,
    (path) => {
      required(path.username);
      required(path.email);
    },
    {
      submission: {
        action: async (field) => {
          const result = await this.api.register(field().value());
          if (result.ok) return; // success
          // route the server's answer to the username field
          return {fieldTree: field.username, kind: 'usernameTaken', message: 'Username is taken'};
        },
        onInvalid: (field) => {
          field().errorSummary()[0]?.fieldTree().focusBoundControl();
        },
      },
    },
  );
}

go deeper

for a junior

Know that submit() or the FormRoot directive runs the action only when the form has no failed rules, and that submitting() tells you a save is in progress.

for a middle

Walk through the four steps, the Promise<boolean> result, ignoreValidators' default, and how an error with fieldTree reaches a specific field.

for a senior

Choose ignoreValidators per form, design the mapping from server error codes to fields, and explain why submission errors clear on edit while rule errors recompute.

for a principal

Standardise how server-side validation failures are shaped across services so that every form can route them to fields the same way.

## What submit() is for Submitting a form involves several jobs at once: showing every hidden error, refusing to send invalid data, preventing double submission, calling the server, and putting the server's complaints next to the right inputs. Signal Forms bundles these into one function, `submit()`, and one directive, `FormRoot`. ## The sequence When `submit(registrationForm)` runs, it does the following in order: 1. **Marks interactive fields touched.** Errors that the template shows only after `touched()` now appear. Hidden, disabled and readonly fields are skipped. 2. **Checks validation.** If any rule has failed, the action does **not** run; the optional `onInvalid` callback runs instead, typically to focus the first error. 3. **Runs the action.** The async `action` receives the field tree and a `detail` object with the `root` and `submitted` trees. While it runs, `submitting()` is `true`. 4. **Applies the result.** Returning nothing (or `null`) means success. Returning one or more error objects means failure, and each error is placed on a field. `submit()` returns a `Promise<boolean>`: `true` when the action completed without errors, `false` when validation stopped it or the action returned errors. ## Where the action comes from The action can be given in two places: - in the `submission` option of `form(model, schema, {submission: {action, onInvalid}})`, which is what `FormRoot` uses; - as a second argument to `submit(form, action)` or `submit(form, {action, ...})`, useful for wizards, auto-save or a button outside the `<form>`. Calling `submit()` with no action anywhere throws. ## The FormRoot directive Putting `[formRoot]="registrationForm"` on the `<form>` element (import `FormRoot`) does three things: - sets `novalidate`, so the browser's own constraint checking does not interfere; - prevents the default browser submission; - calls `submit()` on the form when the user submits. ## Pending async checks and ignoreValidators If the username availability check is still running when the user clicks Register, what should happen? The `ignoreValidators` option decides: | Value | Behaviour | |---|---| | `'pending'` (default) | run the action if nothing has failed, even while checks are pending | | `'none'` | run only if every validator has passed; pending checks block | | `'all'` | always run, whatever the validation state (useful for saving drafts) | ## Routing server errors to fields Validation in the browser cannot know that a username was taken a second ago. The server can, so the action maps its answer to errors: ```ts return {fieldTree: field.username, kind: 'usernameTaken', message: 'Username is taken'}; ``` - An error **with** `fieldTree` is shown on that field's `errors()`. - An error **without** `fieldTree` lands on the submitted field, usually the form root. - An array returns several errors at once. These **submission errors** behave differently from rule errors: they are one-time results. They appear alongside validation errors in `errors()`, **clear as soon as the user edits** that field, and do not come back until the next submission. ## Calling submit() yourself `FormRoot` covers the classic `<form>` with a submit button. Other flows call `submit()` directly: a multi-step registration wizard that submits one step's sub-tree, an auto-save that fires on a timer, or a button rendered outside the `<form>` element. Passing a sub-tree, such as `submit(registrationForm.account, action)`, touches and validates only that part, and the action receives both the submitted sub-tree and, through `detail.root`, the whole form. Because the call returns a `Promise<boolean>`, the caller can `await` it and navigate only on `true`. ## Double submission While a submission is in progress, further `submit()` calls on the same form, or on any of its parents, return `false` immediately without running the action. Binding `[disabled]="registrationForm().submitting()"` on the button additionally tells the user something is happening. ## Summary for an interview - one call handles touch, gate, run and error routing; - failed rules block, pending checks do not unless `ignoreValidators: 'none'`; - server errors target fields with `fieldTree` and self-clear on edit; - concurrent submits are refused.

  • The registration action should not run while the username check is still pending. What do you change?
    Set `ignoreValidators: 'none'` in the submission options. With the default `'pending'`, only failed rules block the action; with `'none'`, every validator must have passed, so a pending availability check stops the submission.
  • How can the page focus the first invalid input when submission is refused?
    Use the `onInvalid` callback. It runs after all interactive fields are marked touched; read `field().errorSummary()`, take the first error's `fieldTree` and call `focusBoundControl()` on its state.

saying these in an interview costs you the question

  • submit() sends the data even if a required field is empty
  • A server error returned from the action stays on the field until the next submit
  • By default a pending async validator always blocks the action
  • You must add novalidate and call preventDefault yourself when using FormRoot
  • A second submit() during an in-flight action queues another request