Migrate Reactive Forms to Signal Forms

October 13, 2025 | 16 Minute Read

Angular’s experimental Signal Forms just landed, and in this tutorial, we’ll migrate a real-world Reactive Form to this new model. Same user experience, cleaner, truly reactive code. Let’s check it out!

Demo Preview: Signup Form Behavior

First, let’s take a look at the app that we’ll be working on.

It’s a very basic signup form:

A very basic signup form built with Reactive Forms in Angular

When we click into the name field and then blur it, we see an error message letting us know that this field is required:

The signup form with the name field blurred showing the required error message

Then when we focus and blur the email field, same thing:

The signup form with the email field blurred showing the required error message

Also, while this form is invalid, the submit button appears disabled:

The signup form with the submit button disabled because the form is invalid

Once we add a valid name, the error message goes away.

For the email, once we enter a value, the error message changes if we have a value that’s not a valid email address:

The signup form with the email field entered with a value that's not a valid email address showing the invalid email error message

Once we have the correct email format, that error goes away and the button becomes enabled:

The signup form with all fields filled out and the form is valid so the submit button is enabled

Currently, this is all done using Reactive Forms which are great, but the bummer is that they don’t use signals, right?

And this is why we want to switch over to signal-based forms.

First, though, let’s look at the code to get a better understanding of how everything works currently.

Reactive Forms Setup: TypeScript and Template

In the component’s TypeScript, we can see the form property that is composed with a FormGroup, the classic Reactive Forms API:

protected form = new FormGroup<SignUpForm>({
    ...
});

Inside it, we have a name FormControl that expects a string and is required:

name: new FormControl<string>('', { 
    nonNullable: true, 
    validators: Validators.required
})

Then we have the emailAddress FormControl that also expects a string, is also required, and includes an email validator to make sure it’s a valid format:

emailAddress: new FormControl<string>('', {
    nonNullable: true, 
    validators: [
        Validators.required,
        Validators.email
    ]
})

That’s why we got two different error messages depending on the state.

In the template, we have a class that’s added based on the form’s validity and “touched” state:

<article [class.invalid]="form.invalid && form.touched">
    ...
</article>

That’s how we get the red border when validation kicks in.

We’re using the formGroup directive to bind our form on a wrapper div:

<div [formGroup]="form">
    ...
</div>

This wires the template to the Angular FormGroup.

The name field uses the formControlName directive:

<input type="text" formControlName="name" />

Below that, we have the validation message that shows when the name control is invalid and “touched”:

<div 
    [class.visible]="name.invalid && name.touched"
    class="error">
    Your name is required!
</div>

Then we have the same setup for the email field.

We have the input bound to the emailAddress control:

<input type="email" formControlName="emailAddress" autocomplete="none" />

Then its validation shows when the control is invalid and “touched” as well, but the message is a little different:

<div 
    [class.visible]="emailAddress.invalid && emailAddress.touched"
    class="error">
    @if (emailAddress.hasError('required')) {
        Your email is required!
    } @else {
        Please enter a valid email address!
    }
</div>

In this case we need to show either the “required” message or the “improper format” message.

Below all of this, the button gets a “disabled” class whenever the form isn’t valid:

<button [class.disabled]="form.invalid">Continue</button>

So that’s our “before”, now let’s switch this to signal-based forms!

Heads-Up: Signal Forms Are Experimental (Angular v21+)

Before we change anything, it’s important to note that Signal Forms are currently experimental!

The API may still change because the Angular team is iterating quickly, but this gives us an early look and a chance to provide feedback.

Use with caution in production!

You’ll also need Angular v21 or a compatible next build to access these features.

Migration Step-by-Step: Reactive → Signal Forms

Back in the TypeScript, we’ll migrate the form model to signals and validation to the new schema style.

First, we’ll update the interface for the form object switching from FormControl types to plain strings:

Before:

interface SignUpForm {
    name: FormControl<string>;
    emailAddress: FormControl<string>;
}

After:

interface SignUpForm {
    name: string;
    emailAddress: string;
}

This makes the data a simple, serializable object, perfect for signals.

Then, we’ll remove the entire form group and its controls. We won’t need them anymore.

We can also remove the ReactiveFormsModule from the component imports and delete the old form imports.

Now, we’re ready to bring in the new Signal Forms features.

Signal Forms vs Reactive Forms: Key Differences

In classic Reactive Forms, your mental model is a tree of classes (form groups and form controls), and you subscribe to observables to react to updates.

With Signal Forms, the mental model shifts to:

  • A plain data object wrapped in a signal (your single source of truth for form values)
  • A form wrapper that gives you field-level signals for state (invalid, touched, errors) and value
  • Template bindings that read these signals directly: no observables, no formControlName, and no formGroup wrapper

TypeScript: Model Signal, form(), and Validators

First, in the template, we’ll be using a new Control directive, so let’s add it to the component imports array:

import { Control } from '@angular/forms/signals';

@Component({
    selector: 'app-form',
    ...,
    imports: [ Control ]
})

Next, let’s create a signal for the form’s data model, a single source of truth holding the form values as a plain object:

protected readonly model = signal<SignUpForm>({ 
    name: '', 
    emailAddress: '' 
});

The form layer will keep this in sync with the actual inputs automatically.

No manual subscriptions or setters.

Then, we’ll create a property for our form using the new form() function from the Signal Forms module.

import { ..., form } from '@angular/forms/signals';

protected readonly form = form(this.model, schema => {});

It wraps our model signal and provides form-level reactivity.

Next, we’ll define a custom validation schema using the new validation functions.

We’ll make the name field required with a custom error message using the new required() function:

import { ..., required } from '@angular/forms/signals';

protected readonly form = form(this.model, schema => {
    required(schema.name, { 
        message: 'Your name is required!'
    });
});

We’ll need to do the same for emailAddress:

protected readonly form = form(this.model, schema => {
    ...
    required(schema.emailAddress, { 
        message: 'Your email is required!'
    });
});

Then, in the case of the emailAddress control, we need to add an additional email format validator, right?

Well, we can use the new email() validator function for this:

protected readonly form = form(this.model, schema => {
    ...
    email(schema.emailAddress, { 
        message: 'Please enter a valid email address!' 
    });
});

Each rule defines both the condition and the message that appears when it fails.

With that, our form logic is fully converted to signals.

Template: [control] Directive and Error Messages

Now let’s update the template.

So now, the form property is a signal so we need to add parentheses to this property in the class binding.

Both invalid and touched are signals now too:

<article [class.invalid]="form().invalid() && form().touched()">
    ...
</article>

With signal-based forms, we no longer have the need for the formGroup directive so we can just remove that.

Before:

<div [formGroup]="form">
    ...
</div>

After:

<div>
    ...
</div>

Next, we used to need to access the controls object to access the specific control from a form:

@let name = form.controls.name;
@let emailAddress = form.controls.emailAddress;

But not anymore, so both of these variables can be shortened:

@let name = form.name;
@let emailAddress = form.emailAddress;

For the name field, we’ll replace formControlName with the new [control] directive:

<input type="text" [control]="name" />

The error message classes now rely on signals, and we’ll use the errors array with its message property to show the proper validation text:

<div 
    [class.visible]="name().invalid() && name().touched()"
    class="error">
    {{ name().errors()[0]?.message }}
</div>

We’ll do the same for the email field: swap in the [control] directive:

<input type="email" [control]="emailAddress" autocomplete="none" />

Then we’ll update bindings to use signals, and simplify error message rendering:

<div 
    [class.visible]="emailAddress().invalid() && emailAddress().touched()"
    class="error">
    {{ emailAddress().errors()[0]?.message }}
</div>

Finally, the submit button’s disabled class will also bind to a signal:

<button [class.disabled]="form().invalid()">Continue</button>

To better visualize how the form and model interact, we’ll add the JSON output of our model signal right in the template, super helpful for debugging and understanding signal flow:

<pre>{{ model() | json }}</pre>

Live Test: Validate, Errors, and Button State

Let’s save and try it out!

Click into the name field and blur, nice! The error message shows up:

The signup form with the name field blurred showing the required error message

Let’s do the same for the email address:

The signup form with the email field blurred showing the required error message

Still works!

The button is disabled while invalid, just like before:

The signup form with the submit button disabled because the form is invalid

Now, let’s type a valid name:

The signup form with the name field entered with a valid name and the error message disappears

The error disappears, and the model signal updates immediately in sync with what we type.

That’s all thanks to the new signal-based form() function.

When we enter an invalid email, we still get the proper message:

The signup form with the email field entered with a value that's not a valid email address showing the invalid email error message

And once we fix it, everything updates:

The sign-up form updated to Signal Forms with all fields filled out and the form is valid so the submit button is enabled and the values are updated in the model signal

The button enables, and the UI reacts instantly.

Same behavior, but now fully signal-native.

No more FormGroup or FormControl ceremony in the class, and our validation messages live right next to the rules that trigger them.

Wrap-Up: Takeaways, Gotchas, and Next Steps

We just migrated a real-world, user-facing form from Reactive Forms to the new, experimental Signal Forms using:

  • A simple data model signal
  • A clean schema with error messages
  • Tight, readable template bindings

And there’s still so much more we can do, like adding custom validators, stacking multiple errors, or even wiring up form submission as a signal.

This is a huge step forward for Angular’s reactivity story, and it’s exciting to see where it’s headed.

If you found this helpful, be sure to subscribe, and let me know how you’re feeling about Signal Forms in the comments.

Additional Resources

Try It Yourself

Want to experiment with the final version? Explore the full StackBlitz demo below.

If you have any questions or thoughts, don’t hesitate to leave a comment.