DEV Community

Nhan Nguyen
Nhan Nguyen

Posted on

Building a Reactive Login Form with Angular Signal Forms

Signal-based forms are one of the coolest new additions to Angular’s ecosystem.

Let’s walk through what the sample login form above is doing and why it’s nice to work with.

1. Modeling the form with signals

First, we define a simple data model:

type LoginData = {
  email: string;
  password: string;
};
Enter fullscreen mode Exit fullscreen mode

Then we create a signal that holds that model:

loginModel = signal<LoginData>({
  email: '',
  password: '',
});
Enter fullscreen mode Exit fullscreen mode

This signal is our single source of truth for the form’s state. Instead of separate controls, we bind the whole object and let the form() helper take care of wiring it up.

2. Creating a signal form

loginForm = form(this.loginModel, (login) => {
  required(login.email, { message: 'Email is required' });
  email(login.email, { message: 'Enter a valid email address' });

  required(login.password, { message: 'Password is required' });
  pattern(
    login.password,
    /^((?=\S*?[A-Z])(?=\S*?[a-z])(?=\S*?[0-9]).{6,})\S$/,
    {
      message:
        'Password must be at least 6 characters long and include at least one uppercase letter, one lowercase letter, and one number, with no spaces.',
    }
  );
});
Enter fullscreen mode Exit fullscreen mode

What’s going on here?

  • form(this.loginModel, …) creates a signal form bound to the loginModel signal.

  • Inside the callback, login exposes field-level signals like login.email and login.password.

  • We attach validation rules using helpers like:

    • required(field, { message })
    • email(field, { message })
    • pattern(field, regex, { message })

Each field now knows:

  • Its current value (as a signal)

  • Its validation status

  • Its list of errors (if any)

3. Binding to the template with and [field]

In the template, we don’t deal with [(ngModel)] or formControlName. Instead, we use the Field standalone component and bind the field signal:

<input type="email" [field]="loginForm.email" />
Enter fullscreen mode Exit fullscreen mode

That’s enough for Angular to:

  • Set the input’s value

  • Update the field when the user types

  • Trigger validation

To show validation errors, we just read from the field’s errors() signal:

@for (err of loginForm.email().errors(); track err.kind) {
  <p style="color: red; font-size: smaller">*{{ err.message }}</p>
}
Enter fullscreen mode Exit fullscreen mode

Same for the password field.

And because everything is signal-based, we can easily display live values:

<p>Hello {{ loginForm.email().value() }}!</p>
<p>Password length: {{ loginForm.password().value().length }}</p>
Enter fullscreen mode Exit fullscreen mode

No async pipes, no manual subscription management—just reading signal values.

4. Reacting to form changes with RxJS

Signals are great, but sometimes you still want an RxJS stream—for example, to debounce user input before saving or logging.

That’s what this part does:

constructor() {
  toObservable(this.loginForm().value)
    .pipe(
      debounceTime(500),
      distinctUntilChanged(),
      takeUntilDestroyed(this.destroyRef)
    )
    .subscribe((value) => {
      console.log(value);
    });
}
Enter fullscreen mode Exit fullscreen mode

Breakdown:

  • this.loginForm().value is a signal representing the full form value.

  • toObservable(...) converts that signal into an Observable.

  • We apply RxJS operators:

    • debounceTime(500) – wait 500ms after the last change.
    • distinctUntilChanged() – only react when the value actually changes.
    • takeUntilDestroyed(this.destroyRef) – automatically clean up when the component is destroyed.
  • Finally, we subscribe and log the result.

This gives you the best of both worlds:

  • Signals for template binding and simple reactivity.

  • RxJS for more advanced reactive pipelines.

5. Why signal forms feel so good

A few nice properties of this approach:

  • Type-safe by default: The form is built from a typed model (LoginData).

  • No manual subscriptions for validation or display — signals handle it.

  • Template stays clean: [field] is easier to reason about than mixing ngModel, FormControl, and validators.

  • Easy interoperability with existing RxJS-based code via toObservable.

Wrap-up

This small login example shows how signal-based forms can simplify form handling in Angular:

  • Define a typed model.

  • Wrap it with form() and attach validation.

  • Bind fields with [field].

  • Use signals directly in the template.

  • Convert to observables when you need RxJS power.

Example

Happy coding!

I hope you found it helpful. Thanks for reading!
Let's get connected! You can find me on:

Top comments (0)