Form validation is straightforward when all rules can be evaluated locally. A required field can be checked immediately, a string can be validated for length, and a number can be compared with a known range.
Real applications often need more than that.
A registration form may need to check whether an email address already exists. An order form may need to verify product availability. A username field may need to check a remote service. A customer form may need to validate a code against a database.
These checks are asynchronous because they involve I/O.
That creates an important distinction in Blazor:
Synchronous validation
|
v
Validate immediately
Asynchronous validation
|
v
Call API/database
|
v
Wait for result
|
v
Update validation state
The validation operation should not turn into a blocking operation that makes the interface unresponsive or creates unnecessary requests.
This article explains a practical pattern for asynchronous form validation in Blazor, including EditForm, EditContext, custom validation messages, cancellation, race conditions, database checks, API validation, submit handling, and production considerations.
Why Asynchronous Validation Is Different
Consider a normal validation rule:
private bool IsValidAge(int age)
{
return age >= 18;
}
The method completes immediately.
A database validation is different:
var exists = await db.Users
.AnyAsync(x => x.Email == email);
The application has to wait for an external operation.
The same applies to an API:
var response = await Http.GetAsync(
$"api/users/check-email?email={email}");
The important part is await.
The asynchronous operation allows the application to yield while the I/O operation is in progress rather than synchronously blocking the thread.
However, simply putting await inside a validation method doesn't automatically make the validation architecture correct.
The application also needs to handle:
validation timing,
cancellation,
stale responses,
validation message updates,
duplicate requests,
submit behavior,
error handling,
and server-side validation.
Built-In Validation and Remote Validation Solve Different Problems
Blazor's standard validation components are useful for local rules.
For example:
<EditForm Model="Model"
OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="Model.Email" />
<ValidationMessage For="@(() => Model.Email)" />
<button type="submit">
Register
</button>
</EditForm>
A model might contain:
public class RegistrationModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[MinLength(8)]
public string Password { get; set; } = string.Empty;
}
These rules are local.
The application doesn't need a database or API to determine whether the email has a valid format.
Remote validation is different:
Is the email syntactically valid?
|
v
Local validation
Does the email already exist?
|
v
Database/API validation
Keeping these responsibilities separate makes the form easier to reason about.
Using EditContext for Custom Validation
For asynchronous validation, EditContext is useful because it provides access to the form's validation lifecycle.
Create an EditContext:
<EditForm EditContext="_editContext"
OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
<InputText @bind-Value="Model.Email" />
<ValidationMessage For="@(() => Model.Email)" />
<button type="submit">
Register
</button>
</EditForm>
Then:
@code {
private RegistrationModel Model = new();
private EditContext _editContext = default!;
protected override void OnInitialized()
{
_editContext = new EditContext(Model);
}
private async Task HandleValidSubmit()
{
// Submit the validated model.
}
}
The EditContext can also be used to trigger validation and manage custom validation state.
ValidationMessageStore
ValidationMessageStore is useful when validation messages don't come from standard data annotations.
Create one for the form:
private ValidationMessageStore _messageStore = default!;
Initialize it:
protected override void OnInitialized()
{
_editContext = new EditContext(Model);
_messageStore = new ValidationMessageStore(_editContext);
}
A custom message can then be added:
_messageStore.Add(
new FieldIdentifier(Model, nameof(Model.Email)),
"This email address is already registered.");
After changing the validation messages, notify the EditContext:
_editContext.NotifyValidationStateChanged();
This tells the form that its validation state has changed and the UI needs to update.
A Basic Async Email Validation Pattern
A simple implementation can validate an email after the user leaves the field.
<EditForm EditContext="_editContext">
<DataAnnotationsValidator />
<div>
<label>Email</label>
<InputText @bind-Value="Model.Email"
@onblur="ValidateEmailAsync" />
<ValidationMessage For="@(() => Model.Email)" />
</div>
<button type="submit">
Register
</button>
</EditForm>
The validation method can be:
private async Task ValidateEmailAsync(FocusEventArgs _)
{
_messageStore.Clear(
new FieldIdentifier(Model, nameof(Model.Email)));
if (string.IsNullOrWhiteSpace(Model.Email))
{
_editContext.NotifyValidationStateChanged();
return;
}
var exists = await UserService.EmailExistsAsync(
Model.Email);
if (exists)
{
_messageStore.Add(
new FieldIdentifier(
Model,
nameof(Model.Email)),
"This email address is already registered.");
}
_editContext.NotifyValidationStateChanged();
}
The important pattern is:
Clear previous message
|
v
Check local value
|
v
Perform async operation
|
v
Add message if necessary
|
v
Notify validation state changed
Don't Query the Database on Every Keystroke
One of the easiest mistakes is performing a database or API request every time the user changes the field.
For example:
<InputText @bind-Value="Model.Email"
@oninput="ValidateEmailAsync" />
If a user types:
b
ba
bai
baib
baibh
baibha
baibhav@
...
the application can generate a large number of requests.
This is inefficient and can create unnecessary load.
Instead, validate at a meaningful point such as:
blur,form submission,
an explicit "Check" action,
or after a debounce period.
For email uniqueness, validating after the user leaves the field is often sufficient.
Debouncing Async Validation
For search-like validation, a debounce can be useful.
The basic idea is:
User types
|
v
Wait briefly
|
+-- User types again → cancel previous wait
|
v
Call API
A cancellation token can help prevent obsolete requests from continuing unnecessarily.
For example:
private CancellationTokenSource? _validationCts;
private async Task ValidateEmailAsync(FocusEventArgs _)
{
_validationCts?.Cancel();
_validationCts?.Dispose();
_validationCts = new CancellationTokenSource();
var token = _validationCts.Token;
await ValidateEmailCoreAsync(token);
}
The validation operation can then accept the token:
private async Task ValidateEmailCoreAsync(
CancellationToken cancellationToken)
{
_messageStore.Clear(
new FieldIdentifier(Model, nameof(Model.Email)));
_editContext.NotifyValidationStateChanged();
if (string.IsNullOrWhiteSpace(Model.Email))
return;
try
{
var exists = await UserService.EmailExistsAsync(
Model.Email,
cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
if (exists)
{
_messageStore.Add(
new FieldIdentifier(
Model,
nameof(Model.Email)),
"This email address is already registered.");
}
_editContext.NotifyValidationStateChanged();
}
catch (OperationCanceledException)
{
// A newer validation request replaced this one.
}
}
Cancellation becomes especially useful when multiple asynchronous validations can overlap.
The Race Condition Problem
Suppose the user enters:
[email protected]
and validation request A starts.
Before it finishes, the user changes the value to:
[email protected]
and request B starts.
Now imagine:
Request A → slow
Request B → fast
Request B finishes first and says:
[email protected] is available
Then request A finishes and says:
[email protected] is already registered
If the application doesn't associate the result with the value that was actually checked, it could display the wrong validation message.
Cancellation helps, but the server-side operation should also be designed so that the latest state is authoritative.
A simple defensive check is:
var email = Model.Email;
var exists = await UserService.EmailExistsAsync(
email,
cancellationToken);
if (!string.Equals(
email,
Model.Email,
StringComparison.OrdinalIgnoreCase))
{
return;
}
The result is ignored if the user has already changed the field.
Async Validation with an API
A validation service should hide HTTP details from the component.
For example:
public interface IUserValidationService
{
Task<bool> EmailExistsAsync(
string email,
CancellationToken cancellationToken);
}
The component then calls:
var exists = await ValidationService.EmailExistsAsync(
Model.Email,
cancellationToken);
This keeps the UI component focused on UI state.
A service implementation might use:
public async Task<bool> EmailExistsAsync(
string email,
CancellationToken cancellationToken)
{
return await httpClient.GetFromJsonAsync<bool>(
$"api/users/email-exists?email={Uri.EscapeDataString(email)}",
cancellationToken);
}
The important production practice is to pass the cancellation token all the way to the HTTP operation.
Otherwise, cancelling the component's validation task may not actually cancel the underlying network operation.
Async Validation with Entity Framework Core
A server-side service can perform an efficient existence query:
public async Task<bool> EmailExistsAsync(
string email,
CancellationToken cancellationToken)
{
return await dbContext.Users
.AsNoTracking()
.AnyAsync(
user => user.Email == email,
cancellationToken);
}
AnyAsync is appropriate when the application only needs to know whether a matching record exists.
There is no reason to retrieve an entire user entity just to answer a yes/no question.
AsNoTracking is also appropriate for a read-only existence check because the returned entity isn't needed.
The database should also enforce uniqueness.
Application-level validation improves the user experience, but it should not be the only protection against duplicate data.
Validation Should Not Replace Server-Side Rules
This is a critical production rule.
Suppose the client checks:
Email available
Then another request registers the same email before the user's submission reaches the server.
Two requests can therefore pass the client-side availability check.
The database must still enforce the actual uniqueness constraint.
The architecture should be:

Join the conversation! Your thoughts help the community grow.