Are you sure your Blazor forms are really validating what matters? Most bugs I debug in production start with a “green” submit button that happily accepts bad data. Let’s fix that – today you’ll learn exactly how Blazor validation works under the hood and how to add rock‑solid custom rules you can drop into any project.
Blazor validation in 60 seconds (the mental model)
Think of an EditForm
as the conductor. It holds an EditContext
that tracks fields and raises events:
OnFieldChanged
– when any bound input changesOnValidationRequested
– when the form is validated (e.g., submit)
Validators listen to those events and push messages into a ValidationMessageStore
. UI components like ValidationSummary
and ValidationMessage
render those messages. That’s it.
Key players:
<EditForm>
– the form wrapperEditContext
– state machine for validation & trackingDataAnnotationsValidator
– built‑in attribute‑based validatorValidationMessageStore
– where error strings live<ValidationSummary/>
and<ValidationMessage For="…"/>
– how errors show up
If you can write to a ValidationMessageStore
, you can validate anything – synchronously, asynchronously, server responses, you name it.
Quick start: EditForm + Data Annotations
@page "/users/new"
<EditForm EditContext="_editContext" OnValidSubmit="SaveAsync">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="mb-3">
<label>First name</label>
<InputText @bind-Value="Model.FirstName" class="form-control" />
<ValidationMessage For="() => Model.FirstName" />
</div>
<div class="mb-3">
<label>Email</label>
<InputText @bind-Value="Model.Email" class="form-control" />
<ValidationMessage For="() => Model.Email" />
</div>
<button class="btn btn-primary" type="submit">Create</button>
</EditForm>
@code {
private readonly UserDto Model = new();
private EditContext _editContext = default!;
protected override void OnInitialized()
{
_editContext = new EditContext(Model);
}
private Task SaveAsync()
{
// Persist to API/db here; only runs if validation passes
return Task.CompletedTask;
}
}
public class UserDto
{
[Required, StringLength(50)]
public string FirstName { get; set; } = string.Empty;
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
}
Tip: Prefer nullable‑aware models (
string
defaulting tostring.Empty
and[Required]
). You’ll get fewer surprises when binding.
Custom rule #1: Strong password via ValidationAttribute
When Data Annotations aren’t enough, write your own attribute.
public sealed class StrongPasswordAttribute : ValidationAttribute
{
public int MinLength { get; init; } = 8;
protected override ValidationResult? IsValid(object? value, ValidationContext context)
{
var s = value as string ?? string.Empty;
if (s.Length < MinLength)
return new($"At least {MinLength} characters.");
if (!s.Any(char.IsUpper))
return new("Must contain an uppercase letter.");
if (!s.Any(char.IsDigit))
return new("Must contain a digit.");
const string specials = "!@#$%^&*()_+-=[]{}|;':\",.<>/?`~";
if (!s.Any(ch => specials.Contains(ch)))
return new("Must contain a special character.");
return ValidationResult.Success;
}
}
Use it on your model:
public class RegisterDto
{
[Required]
[StrongPassword(MinLength = 10, ErrorMessage = "Password not strong enough")]
public string Password { get; set; } = string.Empty;
}
Custom rule #2: Cross‑field validation with IValidatableObject
Need to compare fields or express conditional business logic? Implement IValidatableObject
.
public class RegisterDto : IValidatableObject
{
[Required] public string Password { get; set; } = string.Empty;
[Required] public string ConfirmPassword { get; set; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (Password != ConfirmPassword)
yield return new ValidationResult(
"Passwords must match.", new[] { nameof(ConfirmPassword) });
}
}
Pro move: return the property names related to the error (second parameter). Blazor then shows the message next to the correct input.
Custom rule #3: Conditional requirements (country → state)
Sometimes a field is required only if another field has a specific value.
public class AddressDto : IValidatableObject
{
[Required] public string Country { get; set; } = string.Empty;
public string? State { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (Country.Equals("US", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(State))
{
yield return new ValidationResult(
"State is required for US addresses.", new[] { nameof(State) });
}
}
}
This scales to other scenarios: VAT ID required for businesses, date ranges where Start <= End
, etc.
Custom rule #4: Async/remote validation (username uniqueness)
There’s no async hook in ValidationAttribute
, but EditContext
events + ValidationMessageStore
make it simple.
@inject HttpClient Http
<EditForm EditContext="_editContext" OnValidSubmit="RegisterAsync">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="mb-3">
<label>Username</label>
<InputText @bind-Value="Model.UserName" class="form-control" />
<ValidationMessage For="() => Model.UserName" />
</div>
<button class="btn btn-primary" type="submit">Register</button>
</EditForm>
@code {
private readonly SignUpDto Model = new();
private EditContext _editContext = default!;
private ValidationMessageStore _messages = default!;
private CancellationTokenSource? _cts; // debounce/abort inflight checks
protected override void OnInitialized()
{
_editContext = new EditContext(Model);
_messages = new ValidationMessageStore(_editContext);
_editContext.OnFieldChanged += async (sender, e) =>
{
if (e.FieldIdentifier.FieldName != nameof(Model.UserName)) return;
_cts?.Cancel();
_cts = new CancellationTokenSource();
var ct = _cts.Token;
// Clear previous messages for this field
_messages.Clear(e.FieldIdentifier);
_editContext.NotifyValidationStateChanged();
try
{
await Task.Delay(300, ct); // simple debounce
var exists = await Http.GetFromJsonAsync<bool>($"api/users/exists/{Model.UserName}", ct);
if (exists)
{
_messages.Add(e.FieldIdentifier, "This username is already taken.");
_editContext.NotifyValidationStateChanged();
}
}
catch (OperationCanceledException) { /* typing… */ }
};
}
private async Task RegisterAsync()
{
// Will only fire if there are no validation errors
await Task.CompletedTask;
}
}
public class SignUpDto
{
[Required, StringLength(20, MinimumLength = 3)]
public string UserName { get; set; } = string.Empty;
}
Pattern recap: listen to
OnFieldChanged
, debounce, call your API, add/remove messages viaValidationMessageStore
, and notify.
Showing server‑side API errors on submit
Even with client validation, the source of truth is your API. When it returns validation problems, map them back to fields.
@inject HttpClient Http
<EditForm EditContext="_editContext" OnValidSubmit="SaveAsync">
<DataAnnotationsValidator />
<ValidationSummary />
<!-- form fields here -->
<ServerValidator @ref="_serverValidator" />
<button class="btn btn-primary" type="submit">Save</button>
</EditForm>
@code {
private readonly UserDto Model = new();
private EditContext _editContext = default!;
private ServerValidator _serverValidator = default!;
protected override void OnInitialized()
{
_editContext = new EditContext(Model);
}
private async Task SaveAsync()
{
_serverValidator.ClearErrors();
var response = await Http.PostAsJsonAsync("api/users", Model);
if (!response.IsSuccessStatusCode)
{
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
if (problem?.Errors is not null)
{
_serverValidator.DisplayErrors(problem.Errors);
return; // stop, form now shows server messages
}
}
// proceed on success
}
}
ServerValidator
component (reusable):
@using Microsoft.AspNetCore.Components.Forms
@inherits ComponentBase
<CascadingParameter] public EditContext CurrentEditContext { get; set; } = default!;
@code {
private ValidationMessageStore? _messages;
protected override void OnParametersSet()
{
_messages ??= new ValidationMessageStore(CurrentEditContext);
}
public void DisplayErrors(IDictionary<string, string[]> errors)
{
_messages!.Clear();
foreach (var (field, messages) in errors)
{
var fieldIdentifier = string.IsNullOrWhiteSpace(field)
? new FieldIdentifier(CurrentEditContext.Model, string.Empty)
: new FieldIdentifier(CurrentEditContext.Model, field);
_messages.Add(fieldIdentifier, messages);
}
CurrentEditContext.NotifyValidationStateChanged();
}
public void ClearErrors()
{
_messages?.Clear();
CurrentEditContext?.NotifyValidationStateChanged();
}
}
Works great with ASP.NET Core’s
[ApiController]
, which returnsValidationProblemDetails
automatically when model binding fails.
Friendlier UX: CSS classes and focusing the first error
1) Custom field CSS classes (Bootstrap‑style is-valid
/is-invalid
)
public sealed class BootstrapFieldClassProvider : FieldCssClassProvider
{
public override string GetFieldCssClass(EditContext editContext, in FieldIdentifier field)
{
var isValid = !editContext.GetValidationMessages(field).Any();
var isModified = editContext.IsModified(field);
if (!isModified) return string.Empty;
return isValid ? "is-valid" : "is-invalid";
}
}
Register on the context:
_editContext.FieldCssClassProvider = new BootstrapFieldClassProvider();
Then your inputs can style themselves accordingly (Bootstrap will paint borders):
<InputText @bind-Value="Model.Email" class="form-control" />
2) Focus the first invalid field on submit
wwwroot/js/validation.js
window.focusFirstInvalid = () => {
const el = document.querySelector('.is-invalid, .invalid, .input-validation-error');
if (el) el.focus();
};
Call it when validation fails:
@inject IJSRuntime JS
<button @onclick="TrySubmit" class="btn btn-primary">Submit</button>
@code {
private async Task TrySubmit()
{
if (!_editContext.Validate())
{
await JS.InvokeVoidAsync("focusFirstInvalid");
return;
}
await SaveAsync();
}
}
Reusable patterns you’ll use again and again
- Attribute for single‑field rule →
ValidationAttribute
- Cross‑field rule →
IValidatableObject
- Async/remote rule →
EditContext.OnFieldChanged
+ValidationMessageStore
- Server errors → small
ServerValidator
component above - UX polish → custom
FieldCssClassProvider
+ JS focus helper
Keep these five in your toolbox and you can cover almost any form.
Pitfalls I keep seeing (and fixes)
- Forgetting
<DataAnnotationsValidator/>
– no messages will appear. Add it. - Non‑nullable properties without
[Required]
– the UI might still accept empty strings. Use[Required]
. - Nested objects not validating – ensure the child object is instantiated before binding so validators can traverse it.
- Collections – bind with indexers like
Addresses[0].Street
and supplyValidationMessage For="() => Model.Addresses[0].Street"
. - Validating on every keystroke – too chatty. Debounce async checks; use
@bind-Value:event="oninput"
only for short fields. - Displaying server errors in summary only – also attach to specific fields to guide the user.
End‑to‑end example: registration form with everything wired
@page "/signup"
@inject HttpClient Http
@inject IJSRuntime JS
<EditForm EditContext="_ctx" OnValidSubmit="OnValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="row g-3">
<div class="col-md-6">
<label>First name</label>
<InputText @bind-Value="Model.FirstName" class="form-control" />
<ValidationMessage For="() => Model.FirstName" />
</div>
<div class="col-md-6">
<label>Email</label>
<InputText @bind-Value="Model.Email" class="form-control" />
<ValidationMessage For="() => Model.Email" />
</div>
<div class="col-md-6">
<label>Username</label>
<InputText @bind-Value="Model.UserName" class="form-control" />
<ValidationMessage For="() => Model.UserName" />
</div>
<div class="col-md-6">
<label>Password</label>
<InputText @bind-Value="Model.Password" type="password" class="form-control" />
<ValidationMessage For="() => Model.Password" />
</div>
<div class="col-md-6">
<label>Confirm password</label>
<InputText @bind-Value="Model.ConfirmPassword" type="password" class="form-control" />
<ValidationMessage For="() => Model.ConfirmPassword" />
</div>
</div>
<ServerValidator @ref="_serverValidator" />
<div class="mt-3">
<button class="btn btn-primary" @onclick="Submit">Create account</button>
</div>
</EditForm>
@code {
private readonly SignUpModel Model = new();
private EditContext _ctx = default!;
private ValidationMessageStore _messages = default!;
private ServerValidator _serverValidator = default!;
protected override void OnInitialized()
{
_ctx = new EditContext(Model) { FieldCssClassProvider = new BootstrapFieldClassProvider() };
_messages = new ValidationMessageStore(_ctx);
_ctx.OnFieldChanged += async (s, e) =>
{
if (e.FieldIdentifier.FieldName == nameof(Model.UserName))
{
_messages.Clear(e.FieldIdentifier);
try
{
await Task.Delay(250);
var taken = await Http.GetFromJsonAsync<bool>($"api/users/exists/{Model.UserName}");
if (taken)
{
_messages.Add(e.FieldIdentifier, "This username is already taken.");
_ctx.NotifyValidationStateChanged();
}
}
catch { /* ignored for brevity */ }
}
};
}
private async Task Submit()
{
if (!_ctx.Validate())
{
await JS.InvokeVoidAsync("focusFirstInvalid");
return;
}
await OnValidSubmit();
}
private async Task OnValidSubmit()
{
_serverValidator.ClearErrors();
var resp = await Http.PostAsJsonAsync("api/signup", Model);
if (!resp.IsSuccessStatusCode)
{
var problem = await resp.Content.ReadFromJsonAsync<ValidationProblemDetails>();
if (problem?.Errors is not null)
{
_serverValidator.DisplayErrors(problem.Errors);
return;
}
}
// Success path…
}
}
public class SignUpModel : IValidatableObject
{
[Required, StringLength(50)] public string FirstName { get; set; } = string.Empty;
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
[Required, StringLength(20, MinimumLength = 3)] public string UserName { get; set; } = string.Empty;
[Required, StrongPassword(MinLength = 10)] public string Password { get; set; } = string.Empty;
[Required] public string ConfirmPassword { get; set; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (Password != ConfirmPassword)
yield return new("Passwords must match.", new[] { nameof(ConfirmPassword) });
}
}
FAQ: Blazor EditForm validation in practice
Yes. Don’t subscribe to OnFieldChanged
, and in your submit handler call _editContext.Validate()
yourself. You can also avoid @bind-Value:event="oninput"
to reduce chatter.
Make sure nested properties are not null at render time. Use new ChildDto()
by default. For lists, bind using an index (Items[i].Name
) and use ValidationMessage For
with the same expression.
Use ErrorMessageResourceType
/ErrorMessageResourceName
on attributes, or wrap messages in IStringLocalizer
when producing server errors.
Data Annotations are built‑in and great for simple rules. FluentValidation shines for complex rules and keeps models clean, but it’s a third‑party package – use if your team is comfortable adding it.
Create it per form instance. Reusing across pages/components can leak state and messages.
Listen to OnValidationRequested
; if !editContext.Validate()
, aggregate editContext.GetValidationMessages()
and display them in your toast component.
Conclusion: Ship forms that reject bad data the first time
If you remember one thing, make it this: whoever controls the ValidationMessageStore
controls validation. With a couple of small building blocks – custom attributes, IValidatableObject
, and EditContext
hooks – you can express any rule your domain requires, attach server messages to exact fields, and give users a friendly path to fix mistakes.
Try the end‑to‑end sample in your project today, and tell me: what validation rule burned you last time and how would you implement it now? Drop a comment – let’s make broken submits a thing of the past.