Learning Blazor EditForm and Validation

Blazor EditForm Validation: Custom Rules with Examples

Master Blazor EditForm validation with data annotations, custom attributes, cross‑field and async rules. Ready examples and best practices.

.NET Development·By amarozka · September 25, 2025

Blazor EditForm Validation: Custom Rules with Examples

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 changes
  • OnValidationRequested – 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 wrapper
  • EditContext – state machine for validation & tracking
  • DataAnnotationsValidator – built‑in attribute‑based validator
  • ValidationMessageStore – 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 to string.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 via ValidationMessageStore, 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 returns ValidationProblemDetails 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 ruleValidationAttribute
  • Cross‑field ruleIValidatableObject
  • Async/remote ruleEditContext.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 supply ValidationMessage 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

Can I disable validation on field change and only validate on submit?

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.

How do I validate nested models and lists?

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.

How do I localize validation messages?

Use ErrorMessageResourceType/ErrorMessageResourceName on attributes, or wrap messages in IStringLocalizer when producing server errors.

FluentValidation or Data Annotations?

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.

Can I reuse the same EditContext across pages?

Create it per form instance. Reusing across pages/components can leak state and messages.

How do I show a toast with a summary of errors?

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.

Leave a Reply

Your email address will not be published. Required fields are marked *