DEV Community

Cover image for Async Validation in Angular Signal Forms (Complete Guide)
Brian Treese
Brian Treese

Posted on • Originally published at briantree.se

Async Validation in Angular Signal Forms (Complete Guide)

With Angular 21 introducing the new Signal Forms API, we now have a different and more streamlined way to approach async validation. In this post, we'll walk through how async validators work in Signal Forms, including how to set up a debounced username check using validateAsync(), resource(), and custom async errors. You'll see how pending states, real-time feedback, and server-backed checks fit into this updated pattern giving you a clear understanding of how async validation is handled in Angular's modern form system.

Starting with a Basic Signal Form

For this example, we'll be using an Angular form:

Angular signup form built with Signal Forms API showing a username field and email field both filled with valid values, no error messages displayed, and an enabled submit button indicating the form is valid

It has a username field and that field should check the server to see if the username exists as the user types. Easy enough, right?

Angular signup form with Signal Forms API showing the username field focused and being typed into, demonstrating the need for async validation to check username availability in real-time

But there's a twist: this form uses the new Signal Forms API.

So how do we add async validators with Signal Forms?

Well, that's what you'll learn in this tutorial.

And don't worry, it's pretty simple.

Previewing the Angular Form Before Async Validation

But first, let's see how it works before we make any changes.

When we click in and remove the Username and then blur out… we get a required error:

Angular signup form with Signal Forms API showing the username field empty and displaying a required validation error message after the field is blurred, demonstrating client-side validation

Same thing with Email. Blur it out, and the required message pops in:

Angular signup form with Signal Forms API showing the email field empty and displaying a required validation error message after the field is blurred, demonstrating client-side validation

And if we type an invalid email… we get the "invalid email" message:

Angular signup form with Signal Forms API showing the email field entered with an invalid email address and displaying an invalid email format validation error message, demonstrating client-side validation

But what we really want is a way to check whether the username already exists on the server and then show that error immediately while the user is typing.

If you've done this in Reactive Forms or Template-Driven Forms, you know the general idea.

But with the new experimental Signal Forms API, the pattern is different, and that's what we're about to implement.

Understanding the HTML Setup with Signal Forms

Okay, let's look at the template so we can see what's powering all of this.

First, we have our username field:

<input
    id="username"
    type="text"
    [field]="form.username"
    [class.error]="showUsernameError"/>
Enter fullscreen mode Exit fullscreen mode

With Signal Forms, we use the field directive to bind the control to the input.

Then we have a couple of template variables, one that stores the control signal:

@let username = form.username();
Enter fullscreen mode Exit fullscreen mode

And another that makes it easy to check whether the field is in an error state after the user interacts with it using the touched and invalid states of the control:

@let showUsernameError = username.invalid() && username.touched();
Enter fullscreen mode Exit fullscreen mode

Below that, we loop through the field's validation errors and display them based on the value of this variable:

@if (showUsernameError) {
    <ul class="error-message">
        @for (error of username.errors(); track error.kind) {
            <li>{% raw %}{{ error.message }}{% endraw %}</li>
        }
    </ul>
}
Enter fullscreen mode Exit fullscreen mode

And it's the same setup for the email field.

We have the input with the field binding:

<input
    id="email"
    type="email"
    [field]="form.email"
    [class.error]="showEmailError"/>
Enter fullscreen mode Exit fullscreen mode

The variables:

@let email = form.email();
@let showEmailError = email.invalid() && email.touched();
Enter fullscreen mode Exit fullscreen mode

And the conditional error messages:

@if (showEmailError) {
    <ul class="error-message">
        @for (error of email.errors(); track error.kind) {
            <li>{% raw %}{{ error.message }}{% endraw %}</li>
        }
    </ul>
}
Enter fullscreen mode Exit fullscreen mode

Right now, everything is purely client-side. Nothing async yet.

How the Form Model and Validators Work in Signal Forms

Alright, now let's look to the component TypeScript to see how this all works.

At the top, we've got our "model" signal:

interface SignUpForm {
  username: string;
  email: string;
}

protected model = signal<SignUpForm>({
    username: '',
    email: ''
});
Enter fullscreen mode Exit fullscreen mode

This is the source of truth for the form, and it stores the form state as a signal.

Then we create the form using the new form() function, from the Signal Forms API, where we define our validation:

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

protected form = form(this.model, s => {
    required(s.username, {message: 'A username is required'});
        required(s.email, {message: 'An email address is required'});
        email(s.email, {message: 'Please enter a valid email address'});
});
Enter fullscreen mode Exit fullscreen mode

We've got a required() validator on the username and email fields.

Then we have an email() validator to check for a valid email format.

This is how you apply built-in validators with Signal Forms.

Then below that, we have a method that simulates a server call to check whether a username already exists:

private checkUsernameAvailability(username: string): Promise<boolean> {
    return new Promise(resolve => {
        setTimeout(() => {
            const taken = ['admin', 'test', 'brian'];
            resolve(!taken.includes(username.toLowerCase()));
        }, 2500);
    });
}
Enter fullscreen mode Exit fullscreen mode

On the mock server, "admin", "test", and "brian" are all taken.

Right now this method isn't used at all, so let's fix that.


Built for Angular builders. Get some Shieldworks “United by Craft” gear → https://shop.briantree.se

How Async Validation Works in Angular Signal Forms

Async validation means we want Angular to check the server after the user types their username automatically.

We'll add this validator inside our form setup, right alongside the other validators.

And for this, Signal Forms gives us a validateAsync() method:

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

protected form = form(this.model, s => {
    required(s.username);
    required(s.email);
    email(s.email);

    validateAsync(s.username, {
        // ... async validation config
    });
});
Enter fullscreen mode Exit fullscreen mode

We pass in the field we want to validate, in this case username, and then an options object.

Creating Params to Control When Validation Runs

Next, let's define the params function. This lets us customize what value gets passed into the async process:

validateAsync(s.username, {
    params: ({ value }) => {
        const val = value();
        // ...
    }
});
Enter fullscreen mode Exit fullscreen mode

value() gives us the current username value.

We only want to run the async validator if the user has typed something meaningful.

So if the value is empty or shorter than 3 characters, we return undefined:

params: ({ value }) => {
    const val = value();
    if (!val || val.length < 3) return undefined;
    return val;
}
Enter fullscreen mode Exit fullscreen mode

Returning undefined tells Angular: "Hey, don't run the async validator right now."

Returning the value means: "Yes, go validate this."

Building an Async Resource with Factory and resource()

Next up, we need a factory. This creates the actual async resource Angular will use.

For this, we'll use a resource(). This is Angular's way of handling async data over time. It's like a signal designed for async operations:

import { resource } from '@angular/core';

validateAsync(s.username, {
    params: ({ value }) => {
        const val = value();
        if (!val || val.length < 3) return undefined;
        return val;
    }
    factory: username => resource({
        params: username
    })
});
Enter fullscreen mode Exit fullscreen mode

Inside the resource, we need a "loader".

This is the async function that actually hits the server, or in our case, the fake server:

factory: username => resource({
    params: username,
    loader: async ({ params: username }) => {
        const available = await this.checkUsernameAvailability(username);
        return available;
    }
})
Enter fullscreen mode Exit fullscreen mode

So "available" is going to be either true or false. If false, that means the username is taken and we need to show an error.

Handling Async Results with onSuccess and customError

Now we need to turn that boolean result into an actual error if needed.

For this, we add the onSuccess property:

validateAsync(s.username, {
    params: ({ value }) => {
        const val = value();
        if (!val || val.length < 3) return undefined;
        return val;
    },
    factory: username => resource({
        params: username,
        loader: async ({ params: username }) => {
            const available = await this.checkUsernameAvailability(username);
            return available;
        }
    }),
    onSuccess: (result: boolean) => {
      // ...
    }
});
Enter fullscreen mode Exit fullscreen mode

If the username is taken, we return a custom error.

To do this we'll use the new customError function:

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

onSuccess: (result: boolean) => {
    if (!result) {
        return customError({
            kind: 'username_taken',
            message: 'This username is already taken'
        });
    }
    return null;
}
Enter fullscreen mode Exit fullscreen mode

For this, we add an error kind property. Essentially the type of error. In this case, username_taken.

Then we need to provide a message to display for this error.

Then, if we don't have an error, we'll return null because everything is good.

This "kind" property is super helpful because we can look for it in the template later. More on this in a minute.

Adding onError for Failed Async Validation Requests

Finally, we add an onError handler in case the async operation has any issues:

validateAsync(s.username, {
    params: ({ value }) => {
        const val = value();
        if (!val || val.length < 3) return undefined;
        return val;
    },
    factory: username => resource({
        params: username,
        loader: async ({ params: username }) => {
            const available = await this.checkUsernameAvailability(username);
            return available;
        }
    }),
    onSuccess: (result: boolean) => {
        if (!result) {
            return customError({
                kind: 'username_taken',
                message: 'This username is already taken'
            });
        }
        return null;
    },
    onError: (error: unknown) => {
        console.error('Async validation error:', error);
        return null;
    }
});
Enter fullscreen mode Exit fullscreen mode

If anything unexpected happens during validation, we'll log it and return null so the field doesn't stay invalid.

And at this point, our async validator is ready!

Updating the Template for Pending States and Async Errors

Now let's switch back to the template to add this to the UI.

With the async validator, we have access to a "pending" signal, which becomes true whenever the validator is running.

So, after our username input, let's add a pending message using this new signal:

@if (username.pending()) {
    <p class="info">Checking availability...</p>
}
Enter fullscreen mode Exit fullscreen mode

This will now show up while the async validator is running.

Then, we already have a condition to list out any error messages, but unfortunately we can't use this same loop.

Async errors feel different. We don't want to force people to blur the field before they see them. So instead of touched, we need to use dirty:

@if (username.dirty()) {
    <!-- New error here -->
} @else if (showUsernameError) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Then, within this we'll use our same errors loop and we'll check if it's the "username_taken" error.

If it is, we'll add the string interpolated value of our error message:

@if (username.dirty()) {
    @for (error of username.errors(); track error.kind) {
        @if (error.kind === 'username_taken') {
            <p class="error-message">{% raw %}{{ error.message }}{% endraw %}</p>
        }
    }
} @else if (showUsernameError) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

This gives the user immediate feedback as they type.

Okay, that should be everything we need so let's try it out!

Testing the Async Username Validation in the App

Alright, let's type a username we know is already taken:

Angular signup form with Signal Forms API showing the username field entered with a username that is already taken and displaying a pending message while the async validation is running, demonstrating the need for async validation to check username availability in real-time

As we do, we can see the pending message below the field.

Then, once the request is received, we get the error message letting us know this username already exists:

Angular signup form with Signal Forms API showing the username field entered with a username that is already taken and displaying an error message letting us know this username already exists, demonstrating the need for async validation to check username availability in real-time

Once we add one that's available, the error disappears.

Angular signup form with Signal Forms API showing the username field entered with a username that is available and displaying no error message, demonstrating the need for async validation to check username availability in real-time

Our async validation is working exactly how we expect, but we probably should debounce this right?

We shouldn't be hitting our server on every keystroke.

Debouncing the Async Username Check

Debouncing this validator is now incredibly easy with Signal Forms.

All we have to do is go back into our form configuration and add the debounce helper the same way we use the built-in validator helpers:

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

protected form = form(this.model, s => {
    required(s.username);
    required(s.email);
    email(s.email);

    debounce(s.username, 500);

    validateAsync(s.username, {
        ...
    });
});
Enter fullscreen mode Exit fullscreen mode

We just pass in the username field, followed by the duration we want to debounce, 500 milliseconds.

Pretty cool, right?

With Signal Forms it's just that easy!

Final Thoughts: Async Validation in Signal Forms

And that's how you add async validation to Signal Forms.

Clean code, great UX, and hopefully a simpler flow than the old form setups.

You learned how to use validateAsync(), how to hook it into a resource(), how to show pending states, how to display custom async errors, and how to debounce your validator to avoid unnecessary server calls.

If you enjoyed this, be sure to subscribe and leave a comment, it really helps other Angular developers find this content.

And hey, if you want to show a little Angular pride, check out the Shieldworks tees and hoodies here. They're built for devs who treat this work like a real craft.

Additional Resources

Try It Yourself

Want to experiment with async validation in Signal Forms? Explore the full StackBlitz demo below.

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

Top comments (0)