🧩 Hands-On Tutorial

Blazor .NET 8: Forms + Validation Masterclass (EditForm, FluentValidation, Server-side errors)

Most Blazor form tutorials show you an EditForm with a single text box and a submit button. Then they stop. Your real forms have twelve fields, conditional sections, cross-field rules, server errors that need to land on the right input, and users who navigate away without saving. That's the gap this tutorial closes.

You'll build a Profile Settings page — deliberately complex, deliberately realistic. By the end, every pattern here is drop-in reusable across any Blazor form in your app.

What You'll Build

A Profile Settings app (tutorials/blazor/ProfileSettings/) covering every form pattern that matters in production:

  • Reusable input components — text, select, date picker — that wire into EditContext automatically
  • DataAnnotations validation wired to EditForm with field-level and summary display
  • FluentValidation integration via a custom IValidatorInterceptor — same form, richer rules
  • Server-side error mapping — API returns ProblemDetails, errors land on the right field
  • Async username availability check with debounce so you don't hit the server on every keystroke
  • Accessibilityaria-describedby, aria-invalid, live region for validation summary
  • Dirty state detection with a sticky save bar that activates only when something changed
  • Optimistic UI for toggle preferences vs. safe submit for critical fields

Project Setup

Start with a Blazor Web App in .NET 8. The interactive render mode is InteractiveServer for this tutorial — forms are stateful, and SignalR gives you server-side logic without round-tripping JSON for every keystroke. The patterns translate directly to WebAssembly with minor service registration differences.

Terminal
dotnet new blazor -n ProfileSettings --interactivity Server
cd ProfileSettings

dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
dotnet add package Blazored.FluentValidation   # EditForm bridge for FluentValidation

The Profile Model

One model, deliberately complex — enough fields to expose every validation pattern. This is what the whole tutorial works with.

Models/ProfileModel.cs
using System.ComponentModel.DataAnnotations;

public class ProfileModel
{
    [Required, StringLength(30, MinimumLength = 3)]
    [RegularExpression(@"^[a-z0-9_]+$",
        ErrorMessage = "Username can only contain lowercase letters, numbers, and underscores.")]
    public string Username { get; set; } = "";

    [Required, EmailAddress]
    public string Email { get; set; } = "";

    [StringLength(100)]
    public string DisplayName { get; set; } = "";

    [StringLength(500)]
    public string Bio { get; set; } = "";

    public string Country { get; set; } = "";

    [DataType(DataType.Date)]
    public DateTime? DateOfBirth { get; set; }

    public string Theme { get; set; } = "system";

    public bool EmailNotifications { get; set; } = true;
    public bool MarketingEmails    { get; set; } = false;
}
One Model, Multiple Validators

The same ProfileModel works with both DataAnnotations and FluentValidation. You can run them simultaneously on the same form — DataAnnotations fire first via DataAnnotationsValidator, then FluentValidation catches anything DataAnnotations can't express. Sections 4 and 5 show both in detail.

EditForm & EditContext Internals

EditForm is Blazor's form wrapper. It creates an EditContext — a model-bound state container that tracks field modification, validation messages, and whether the form has been submitted. Understanding what EditContext does internally makes everything else in this tutorial make sense.

EditContext: What It Tracks

Three things live in an EditContext: the model, a ValidationMessageStore (where validation messages are written), and field modification flags. When a field is modified, EditContext.NotifyFieldChanged() fires, causing validation to re-run and the component to re-render.

EditContext Lifecycle — Core Pattern
@* ProfileForm.razor *@
@rendermode InteractiveServer

<EditForm EditContext="@_editContext" OnValidSubmit="HandleValidSubmit">
    <DataAnnotationsValidator />

    <!-- Fields here -->

    <ValidationSummary />
    <button type="submit" disabled="@_isSaving">
        @(_isSaving ? "Saving…" : "Save Profile")
    </button>
</EditForm>

@code {
    private ProfileModel      _model       = new();
    private EditContext?      _editContext;
    private ValidationMessageStore? _serverErrors;
    private bool              _isSaving;

    protected override void OnInitialized()
    {
        _editContext  = new EditContext(_model);
        _serverErrors = new ValidationMessageStore(_editContext);

        // Subscribe to field changes — used for dirty state (Section 9)
        _editContext.OnFieldChanged += (_, _) => StateHasChanged();
    }

    private async Task HandleValidSubmit()
    {
        _isSaving = true;
        _serverErrors!.Clear();
        _editContext!.NotifyValidationStateChanged();

        try
        {
            await ProfileService.SaveAsync(_model);
        }
        catch (ApiValidationException ex)
        {
            // Map server errors to fields — covered in Section 6
            MapServerErrors(ex.Errors);
        }
        finally
        {
            _isSaving = false;
        }
    }
}

The Three Submit Handlers

EditForm exposes three callbacks. Pick the right one for each use case:

Submit Handler Options
@* OnValidSubmit — fires only when ALL validators pass. Use this 99% of the time. *@
<EditForm ... OnValidSubmit="HandleValidSubmit">

@* OnInvalidSubmit — fires when validation FAILS. Useful for scroll-to-error UX. *@
<EditForm ... OnInvalidSubmit="ScrollToFirstError">

@* OnSubmit — fires always, regardless of validation. You own the validation call. *@
@* Use when you need to run custom pre-submit logic BEFORE validation. *@
<EditForm ... OnSubmit="HandleSubmit">

@code {
    private async Task HandleSubmit(EditContext ctx)
    {
        if (!ctx.Validate()) return; // manual validation trigger
        await SaveAsync();
    }
}
Always Create EditContext Manually for Complex Forms

You can pass either Model="@_model" or EditContext="@_editContext" to EditForm. Use Model only for the simplest cases. As soon as you need to inject server errors, track dirty state, or control validation timing, create your own EditContext in OnInitialized(). You get full control over the ValidationMessageStore and can call NotifyValidationStateChanged() whenever you need the UI to update.

Reusable Input Components

Default Blazor inputs (InputText, InputSelect, etc.) work but are bare. Every project ends up wrapping them with a label, error display, and consistent styling. Build the wrapper once and use it everywhere.

The Base Pattern: Inheriting InputBase<T>

InputBase<T> handles EditContext wiring, FieldIdentifier, and CurrentValue binding for you. Inherit it to build type-safe custom inputs that participate in form validation automatically.

Components/FormField.razor — Reusable Text Input
@* Wraps InputText with label, error display, and ARIA attributes *@
@typeparam TValue

<div class="form-field @(HasErrors ? "field--error" : "")">
    <label for="@_id">
        @Label
        @if (Required) { <span class="required-mark" aria-hidden="true">*</span> }
    </label>

    <input id="@_id"
           type="@Type"
           value="@CurrentValueAsString"
           @onchange="HandleChange"
           aria-describedby="@(HasErrors ? $"{_id}-error" : null)"
           aria-invalid="@(HasErrors ? "true" : null)"
           aria-required="@(Required ? "true" : null)"
           class="form-input @AdditionalAttributes.GetValueOrDefault("class", "")"
           @attributes="AdditionalAttributes" />

    @if (!string.IsNullOrEmpty(HelpText) && !HasErrors)
    {
        <p id="@_id-help" class="field-help">@HelpText</p>
    }

    @if (HasErrors)
    {
        <p id="@_id-error" class="field-error" role="alert" aria-live="polite">
            <ValidationMessage For="@For" />
        </p>
    }
</div>

@code {
    [Parameter, EditorRequired] public Expression<Func<TValue>> For  { get; set; } = default!;
    [Parameter, EditorRequired] public string                   Label { get; set; } = "";
    [Parameter]                 public string                   Type  { get; set; } = "text";
    [Parameter]                 public string?                  HelpText  { get; set; }
    [Parameter]                 public bool                     Required  { get; set; }
    [CascadingParameter]        public EditContext?             EditCtx   { get; set; }
    [Parameter(CaptureUnmatchedValues = true)]
    public Dictionary<string, object>? AdditionalAttributes { get; set; }

    private string _id = $"field-{Guid.NewGuid():N}";
    private FieldIdentifier _fieldId;

    protected override void OnInitialized()
    {
        _fieldId = FieldIdentifier.Create(For);
    }

    private bool HasErrors => EditCtx?
        .GetValidationMessages(_fieldId).Any() ?? false;

    private void HandleChange(ChangeEventArgs e)
    {
        // Two-way binding via the cascading EditContext
        CurrentValueAsString = e.Value?.ToString();
        EditCtx?.NotifyFieldChanged(_fieldId);
    }
}

Reusable Select & Date Picker

Components/FormSelect.razor
@inherits InputSelect<string>

<div class="form-field @(HasErrors ? "field--error" : "")">
    <label for="@_id">@Label</label>

    <select id="@_id"
            @bind="CurrentValue"
            aria-describedby="@(HasErrors ? $"{_id}-error" : null)"
            aria-invalid="@(HasErrors ? "true" : null)"
            class="form-select">
        @ChildContent
    </select>

    @if (HasErrors)
    {
        <p id="@_id-error" class="field-error" role="alert">
            <ValidationMessage For="@For" />
        </p>
    }
</div>

@code {
    [Parameter, EditorRequired] public Expression<Func<string>> For   { get; set; } = default!;
    [Parameter, EditorRequired] public string                   Label  { get; set; } = "";
    [Parameter]                 public RenderFragment?          ChildContent { get; set; }
    [CascadingParameter]        public EditContext?             EditCtx { get; set; }

    private string _id = $"select-{Guid.NewGuid():N}";
    private bool HasErrors => EditCtx?
        .GetValidationMessages(FieldIdentifier.Create(For)).Any() ?? false;
}

@* Usage in the profile form *@
<FormSelect Label="Country" For="@(() => _model.Country)">
    <option value="">Select a country…</option>
    <option value="US">United States</option>
    <option value="GB">United Kingdom</option>
    <option value="AU">Australia</option>
</FormSelect>
Cascading EditContext

EditForm automatically cascades its EditContext to all descendant components. Any component that declares [CascadingParameter] public EditContext? EditCtx receives it without you wiring it manually. This is how DataAnnotationsValidator, ValidationMessage, and your custom components all stay in sync with the same form state.

Client Validation: DataAnnotations

DataAnnotations validation is built into EditForm. Add <DataAnnotationsValidator /> inside the form and validation fires on submit and on field change. For the profile model, this covers most of the basic rules without any extra packages.

Validation Display Options

Field-Level + Summary Validation Display
@* Inside EditForm *@
<DataAnnotationsValidator />

@* Option A: Summary at the top — all errors at once *@
<ValidationSummary class="validation-summary" />

@* Option B: Per-field messages — inline, contextual *@
<FormField Label="Username"
           For="@(() => _model.Username)"
           Required="true"
           HelpText="3-30 chars, lowercase letters, numbers, underscores only" />

@* ValidationMessage targets a specific field expression *@
<ValidationMessage For="@(() => _model.Email)" />

@* Option C: Both — summary for screen readers, inline for visual users *@
<div class="sr-only" role="alert" aria-live="assertive">
    <ValidationSummary />
</div>
@* Individual ValidationMessage on each field *@

Custom Validation Attribute

When built-in attributes aren't enough, write your own. Keep the attribute thin — complex logic belongs in FluentValidation (Section 5).

Custom Validation Attribute — Date of Birth Range
public class MinimumAgeAttribute : ValidationAttribute
{
    private readonly int _minimumAge;

    public MinimumAgeAttribute(int minimumAge)
    {
        _minimumAge    = minimumAge;
        ErrorMessage   = $"You must be at least {minimumAge} years old.";
    }

    protected override ValidationResult? IsValid(
        object? value, ValidationContext ctx)
    {
        if (value is not DateTime dob) return ValidationResult.Success;

        var age = DateTime.Today.Year - dob.Year;
        if (dob.Date > DateTime.Today.AddYears(-age)) age--;

        return age >= _minimumAge
            ? ValidationResult.Success
            : new ValidationResult(ErrorMessage, [ctx.MemberName!]);
    }
}

// Apply on the model
public class ProfileModel
{
    [DataType(DataType.Date)]
    [MinimumAge(13, ErrorMessage = "You must be at least 13 years old to create an account.")]
    public DateTime? DateOfBirth { get; set; }
}
Validate on Field Change, Not Just Submit

By default, DataAnnotationsValidator validates on submit and when a field loses focus (after first submit). To validate on every keystroke — useful for real-time feedback on a username field — subscribe to _editContext.OnFieldChanged and call _editContext.Validate() manually. Balance eagerness against annoyance: don't show "too short" while the user is still typing.

Client Validation: FluentValidation

FluentValidation handles rules that DataAnnotations can't express cleanly: conditional validation, cross-field comparisons, complex string rules, and validators you can unit-test without HTTP context. The Blazored.FluentValidation package bridges FluentValidation into EditForm with a single component swap.

Register Validators

Program.cs — Register FluentValidation
using FluentValidation;

// Scans the assembly and registers all validators automatically
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

Write the Profile Validator

Validators/ProfileValidator.cs
using FluentValidation;

public class ProfileValidator : AbstractValidator<ProfileModel>
{
    public ProfileValidator()
    {
        RuleFor(x => x.Username)
            .NotEmpty().WithMessage("Username is required.")
            .Length(3, 30).WithMessage("Username must be 3–30 characters.")
            .Matches(@"^[a-z0-9_]+$")
            .WithMessage("Username can only contain lowercase letters, numbers, and underscores.");

        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required.")
            .EmailAddress().WithMessage("Enter a valid email address.");

        RuleFor(x => x.DisplayName)
            .MaximumLength(100)
            .WithMessage("Display name cannot exceed 100 characters.");

        RuleFor(x => x.Bio)
            .MaximumLength(500)
            .WithMessage("Bio cannot exceed 500 characters.");

        // Conditional: date of birth only validated if provided
        When(x => x.DateOfBirth.HasValue, () =>
        {
            RuleFor(x => x.DateOfBirth!.Value)
                .LessThan(DateTime.Today.AddYears(-13))
                .WithMessage("You must be at least 13 years old.")
                .GreaterThan(DateTime.Today.AddYears(-120))
                .WithMessage("Enter a valid date of birth.");
        });

        // Cross-field: Display name must not match username
        RuleFor(x => x.DisplayName)
            .NotEqual(x => x.Username)
            .WithMessage("Display name cannot be the same as your username.");
    }
}

Wire FluentValidation into EditForm

ProfileForm.razor — FluentValidation Integration
@using Blazored.FluentValidation

<EditForm EditContext="@_editContext" OnValidSubmit="HandleValidSubmit">

    @* Replace DataAnnotationsValidator with FluentValidationValidator *@
    <FluentValidationValidator DisableAssemblyScanning="false" />

    <!-- Or run BOTH — DataAnnotations first, FluentValidation adds extra rules *@
    <DataAnnotationsValidator />
    <FluentValidationValidator DisableAssemblyScanning="false" />

    <FormField Label="Username"      For="@(() => _model.Username)"      Required="true" />
    <FormField Label="Email"         For="@(() => _model.Email)"          Required="true" Type="email" />
    <FormField Label="Display Name"  For="@(() => _model.DisplayName)"    HelpText="How your name appears publicly" />
    <FormField Label="Bio"           For="@(() => _model.Bio)"            Type="textarea" />
    <FormSelect Label="Country"      For="@(() => _model.Country)">
        <option value="">Select a country…</option>
        <option value="US">United States</option>
        <option value="GB">United Kingdom</option>
    </FormSelect>
    <FormField Label="Date of Birth"  For="@(() => _model.DateOfBirth)" Type="date" />

    <button type="submit" disabled="@_isSaving">Save Profile</button>
</EditForm>
Unit Testing Validators in Isolation

FluentValidation validators are plain C# classes with no Blazor dependency. Test them directly: var result = new ProfileValidator().Validate(model);. No WebApplicationFactory, no browser. This is the biggest practical advantage over DataAnnotations for complex business rules — every rule is testable in a 5-line unit test.

Server-Side Error Mapping

Your client validation is solid. But the server has rules the client can't know: username already taken (race condition), email blocklisted, content policy violations. These come back as ProblemDetails from your API. They need to land on the right form field, not just in a generic error banner.

The API Returns ProblemDetails

Example API Error Response (RFC-7807)
{
  "type":   "https://tools.ietf.org/html/rfc7807",
  "title":  "Validation Failed",
  "status": 422,
  "errors": {
    "Username": ["This username is already taken."],
    "Email":    ["This email address is already registered.", "Email domain is blocked."]
  }
}

Map API Errors to EditContext Fields

ProfileForm.razor — Server Error Mapping
@code {
    private EditContext?             _editContext;
    private ValidationMessageStore? _serverErrors;
    private ProfileModel             _model = new();
    private bool                     _isSaving;

    protected override void OnInitialized()
    {
        _editContext  = new EditContext(_model);
        _serverErrors = new ValidationMessageStore(_editContext);
    }

    private async Task HandleValidSubmit()
    {
        _isSaving = true;

        // Clear previous server errors before each submit
        _serverErrors!.Clear();
        _editContext!.NotifyValidationStateChanged();

        try
        {
            await ProfileApi.SaveProfileAsync(_model);
            // Success — show confirmation, reset dirty state
            _isDirty = false;
        }
        catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.UnprocessableEntity)
        {
            var problemDetails = await ParseProblemDetailsAsync(ex);
            MapServerErrors(problemDetails.Errors);
        }
        finally
        {
            _isSaving = false;
        }
    }

    private void MapServerErrors(IDictionary<string, string[]> errors)
    {
        foreach (var (fieldName, messages) in errors)
        {
            // Create a FieldIdentifier from the property name string
            var fieldId = _editContext!.Field(fieldName);
            foreach (var message in messages)
                _serverErrors!.Add(fieldId, message);
        }

        // Notify EditContext so ValidationMessage components re-render
        _editContext!.NotifyValidationStateChanged();
    }
}

Clear Server Errors on Field Change

Server errors should disappear as soon as the user edits the offending field — otherwise they look stale and confusing.

Clear Stale Server Errors on Edit
protected override void OnInitialized()
{
    _editContext  = new EditContext(_model);
    _serverErrors = new ValidationMessageStore(_editContext);

    // When any field changes, clear that field's server errors
    _editContext.OnFieldChanged += (_, args) =>
    {
        _serverErrors.Clear(args.FieldIdentifier);
        _editContext.NotifyValidationStateChanged();
        StateHasChanged();
    };
}
Don't Mix Client and Server Error Lifetimes

Client validation errors (from DataAnnotationsValidator or FluentValidationValidator) are managed by the validator component automatically. Server errors live in your own ValidationMessageStore. If you call _serverErrors.Clear() without also calling NotifyValidationStateChanged(), the UI won't update. Always call both together.

Async Validation & Debounce

Some validation rules require a server round-trip: is the username available? Does this email already exist? You can't do this in a synchronous validator. The pattern is: debounce the input, hit the API, write the result into the ValidationMessageStore.

Debounced Username Availability Check

ProfileForm.razor — Async Username Check
@* In the template — username field with availability indicator *@
<div class="field-with-status">
    <FormField Label="Username"
               For="@(() => _model.Username)"
               Required="true"
               @oninput="OnUsernameInput" />

    @if (_checkingUsername)
    {
        <span class="field-status field-status--checking" aria-live="polite">
            Checking availability…
        </span>
    }
    else if (_usernameAvailable.HasValue)
    {
        <span class="field-status @(_usernameAvailable.Value ? "field-status--ok" : "field-status--error")"
              aria-live="polite">
            @(_usernameAvailable.Value ? "✓ Available" : "✗ Already taken")
        </span>
    }
</div>

@code {
    private CancellationTokenSource? _usernameCts;
    private bool                     _checkingUsername;
    private bool?                    _usernameAvailable;
    private FieldIdentifier          _usernameField;

    protected override void OnInitialized()
    {
        _editContext  = new EditContext(_model);
        _serverErrors = new ValidationMessageStore(_editContext);
        _usernameField = _editContext.Field(nameof(ProfileModel.Username));
    }

    private async Task OnUsernameInput(ChangeEventArgs e)
    {
        _model.Username = e.Value?.ToString() ?? "";

        // Cancel the previous pending check
        _usernameCts?.Cancel();
        _usernameCts?.Dispose();
        _usernameCts = new CancellationTokenSource();

        var token    = _usernameCts.Token;
        _usernameAvailable = null;

        // Only check if it passes basic client-side rules first
        if (_model.Username.Length < 3) return;

        _checkingUsername = true;
        StateHasChanged();

        try
        {
            // Debounce: wait 350ms of silence before hitting the server
            await Task.Delay(350, token);

            var available = await UsernameApi.IsAvailableAsync(_model.Username, token);
            _usernameAvailable = available;

            // Write result into the form's ValidationMessageStore
            _serverErrors!.Clear(_usernameField);
            if (!available)
                _serverErrors.Add(_usernameField, "This username is already taken.");

            _editContext!.NotifyValidationStateChanged();
        }
        catch (OperationCanceledException)
        {
            // User typed again before the delay elapsed — silently discard
        }
        finally
        {
            _checkingUsername = false;
            StateHasChanged();
        }
    }

    public void Dispose() => _usernameCts?.Dispose(); // IDisposable on the component
}
Guard Against Race Conditions

If the user types fast, multiple async checks can be in-flight simultaneously. The CancellationTokenSource pattern above ensures only the latest check matters — earlier ones are cancelled. But also validate the result: if the response arrives for "john" but the field now contains "johnny", discard it. Always compare the response to _model.Username before writing to the store.

Accessibility Patterns

Accessible forms aren't a separate concern from functional forms — they're the same thing. Screen reader users need to hear which fields have errors. Keyboard users need focus to land somewhere useful after submit. The browser needs enough context to offer autocomplete. None of this is automatic in Blazor.

ARIA Attributes on Inputs

Accessible Input Pattern
@* Explicit ID linking label → input → error message *@
<label for="username-input">
    Username
    <span class="required-mark" aria-hidden="true">*</span>
    <span class="sr-only">(required)</span>
</label>

<input id="username-input"
       type="text"
       @bind="_model.Username"
       autocomplete="username"
       aria-required="true"
       aria-describedby="@(_usernameHasErrors ? "username-error" : "username-hint")"
       aria-invalid="@(_usernameHasErrors ? "true" : null)" />

@if (!_usernameHasErrors)
{
    <p id="username-hint" class="field-help">
        3–30 characters. Lowercase letters, numbers, and underscores only.
    </p>
}

@if (_usernameHasErrors)
{
    <!-- role="alert" announces immediately on screen readers -->
    <p id="username-error" class="field-error" role="alert">
        <ValidationMessage For="@(() => _model.Username)" />
    </p>
}

Accessible Validation Summary

The default <ValidationSummary /> is a plain <ul>. Screen readers won't announce it unless it's in a live region. Wrap it:

Live Region Validation Summary
@* Visible summary for sighted users *@
@if (_hasSubmitErrors)
{
    	 <div id="form-summary" class="validation-summary-container"
         role="alert"
         aria-live="assertive"
         aria-atomic="true"
         aria-label="Form errors">
        <p class="validation-summary-title">
            Please fix @_errorCount @(_errorCount == 1 ? "error" : "errors") before saving:
        </p>
        <ValidationSummary />
    </div>
}

@* Hidden, always-present live region — screen readers announce changes here *@
<div class="sr-only"
     aria-live="polite"
     aria-atomic="true"
     id="form-status-announcer">
    @_statusMessage
</div>

@code {
    private bool   _hasSubmitErrors;
    private int    _errorCount;
    private string _statusMessage = "";

    private async Task HandleInvalidSubmit()
    {
        _hasSubmitErrors = true;
        _errorCount      = _editContext!.GetValidationMessages().Count();
        _statusMessage   = $"Form has {_errorCount} errors. Please review and correct them.";
        StateHasChanged();

        // Move focus to the summary for keyboard users
        await JS.InvokeVoidAsync("focusElement", "#form-summary");
    }
}

Keyboard Navigation

Focus Management After Submit
@* wwwroot/js/formHelpers.js *@
window.focusElement = function(selector) {
    const el = document.querySelector(selector);
    if (el) {
        el.setAttribute("tabindex", "-1");
        el.focus();
    }
};

window.focusFirstError = function() {
    const firstError = document.querySelector("[aria-invalid='true']");
    if (firstError) firstError.focus();
};

@* In Program.cs — no extra registration needed; IJSRuntime is already registered in Blazor *@

@* In component — move focus to first invalid field on submit failure *@
@inject IJSRuntime JS

private async Task HandleInvalidSubmit()
{
    await JS.InvokeVoidAsync("focusFirstError");
}
Autocomplete Attributes Matter for Autofill Security

Use autocomplete="username", autocomplete="email", autocomplete="current-password", and autocomplete="new-password" precisely. Wrong or missing values mean password managers fill the wrong fields — or worse, fill a new password field with the current password. This is both a UX and a security issue. The WHATWG spec defines the full list of valid values.

Dirty State & Save Bar

Dirty state: has the user changed anything since the form loaded? Two UX patterns depend on it — a sticky save bar that activates only when there are unsaved changes, and a navigation guard that warns before losing those changes. Both are table-stakes for settings pages.

Track Dirty State via EditContext

Dirty State Detection
@code {
    private ProfileModel _model        = new();
    private ProfileModel _originalModel = new(); // snapshot on load
    private bool         _isDirty;

    protected override async Task OnInitializedAsync()
    {
        _model = await ProfileApi.GetCurrentAsync();
        // Deep copy for comparison — use a record Clone or AutoMapper
        _originalModel = _model with { };

        _editContext = new EditContext(_model);
        _editContext.OnFieldChanged += (_, _) =>
        {
            // Compare current model to original snapshot
            _isDirty = HasModelChanged();
            StateHasChanged();
        };
    }

    private bool HasModelChanged() =>
        _model.Username      != _originalModel.Username      ||
        _model.Email         != _originalModel.Email         ||
        _model.DisplayName   != _originalModel.DisplayName   ||
        _model.Bio           != _originalModel.Bio           ||
        _model.Country       != _originalModel.Country       ||
        _model.DateOfBirth   != _originalModel.DateOfBirth   ||
        _model.Theme         != _originalModel.Theme         ||
        _model.EmailNotifications != _originalModel.EmailNotifications;

    private async Task HandleValidSubmit()
    {
        await ProfileApi.SaveAsync(_model);
        // Reset dirty state — update snapshot to saved state
        _originalModel = _model with { };
        _isDirty = false;
    }

    private void DiscardChanges()
    {
        _model    = _originalModel with { };
        _isDirty  = false;
        // Rebuild EditContext with the reset model
        _editContext = new EditContext(_model);
        _editContext.OnFieldChanged += (_, _) => { _isDirty = HasModelChanged(); StateHasChanged(); };
    }
}

Sticky Save Bar Component

Components/SaveBar.razor
@* Animated sticky bar — slides in from bottom when IsDirty is true *@
<div class="save-bar @(IsDirty ? "save-bar--visible" : "")"
     role="status"
     aria-live="polite"
     aria-label="Unsaved changes">
    <span class="save-bar__message">You have unsaved changes</span>
    <div class="save-bar__actions">
        <button type="button"
                class="btn btn--ghost"
                @onclick="OnDiscard"
                disabled="@IsSaving">
            Discard
        </button>
        <button type="submit"
                form="@FormId"
                class="btn btn--primary"
                disabled="@IsSaving">
            @(IsSaving ? "Saving…" : "Save Changes")
        </button>
    </div>
</div>

@code {
    [Parameter, EditorRequired] public bool     IsDirty  { get; set; }
    [Parameter, EditorRequired] public bool     IsSaving { get; set; }
    [Parameter, EditorRequired] public string   FormId   { get; set; } = "";
    [Parameter]                 public EventCallback OnDiscard { get; set; }
}

@* Usage in profile page *@
<EditForm id="profile-form" EditContext="@_editContext" OnValidSubmit="HandleValidSubmit">
    @* ... fields ... *@
</EditForm>

<SaveBar IsDirty="@_isDirty"
         IsSaving="@_isSaving"
         FormId="profile-form"
         OnDiscard="DiscardChanges" />

Navigation Guard

Navigation Guard — Warn on Unsaved Changes
@implements IDisposable
@inject NavigationManager Nav

@code {
    private IDisposable? _navRegistration;

    protected override void OnInitialized()
    {
        // RegisterLocationChangingHandler fires BEFORE navigation completes
        _navRegistration = Nav.RegisterLocationChangingHandler(OnLocationChanging);
    }

    private async ValueTask OnLocationChanging(LocationChangingContext ctx)
    {
        if (!_isDirty) return; // no unsaved changes — allow navigation

        // Ask the user to confirm
        var confirmed = await JS.InvokeAsync<bool>(
            "confirm",
            "You have unsaved changes. Leave without saving?");

        if (!confirmed)
            ctx.PreventNavigation(); // block navigation
    }

    public void Dispose() => _navRegistration?.Dispose();
}
RegisterLocationChangingHandler Is Blazor-Only

NavigationManager.RegisterLocationChangingHandler intercepts Blazor client-side navigation — clicking a NavLink, calling Nav.NavigateTo(). It does not intercept browser tab closes, address bar navigation, or refreshes. For those cases, use the browser's beforeunload event via JSInterop: window.addEventListener('beforeunload', e => { e.preventDefault(); }). Wire it up in OnAfterRenderAsync and clean it up in Dispose().

Optimistic UI vs Safe Submit

Not all form fields carry equal risk. A theme preference toggle feels sluggish if you disable it and wait for the server. A password change must wait — getting the response wrong has real consequences. Use the right strategy for each type of field.

Optimistic UI: Theme & Notification Toggles

Optimistic Toggle — Theme Preference
@code {
    private string _theme       = "system";
    private string _savedTheme  = "system"; // rollback target
    private bool   _themeSaving;
    private string? _themeError;

    private async Task OnThemeChange(string newTheme)
    {
        _savedTheme  = _theme;          // snapshot before change
        _theme       = newTheme;        // update immediately — optimistic
        _themeError  = null;
        _themeSaving = true;
        StateHasChanged();              // UI updates now, before API call

        try
        {
            await PreferencesApi.SetThemeAsync(newTheme);
            // Success — nothing to do, UI is already correct
        }
        catch
        {
            // Failure — roll back silently with a notification
            _theme      = _savedTheme;
            _themeError = "Couldn't save theme. Please try again.";
        }
        finally
        {
            _themeSaving = false;
            StateHasChanged();
        }
    }
}

@* Template *@
<fieldset disabled="@_themeSaving">
    <legend>Theme</legend>
    @foreach (var theme in new[] { "light", "dark", "system" })
    {
        <label>
            <input type="radio"
                   name="theme"
                   value="@theme"
                   checked="@(_theme == theme)"
                   @onchange="() => OnThemeChange(theme)" />
            @theme
        </label>
    }
</fieldset>

@if (_themeError is not null)
{
    <p class="field-error" role="alert">@_themeError</p>
}

Safe Submit: Critical Field Changes

Email and password changes need server confirmation before updating the UI. Show a spinner, wait for the response, then update.

Safe Submit — Email Change with Confirmation
@code {
    private string _newEmail    = "";
    private bool   _emailSaving;
    private bool   _emailSaved;
    private string? _emailError;

    private async Task ChangeEmail()
    {
        _emailSaving = true;
        _emailError  = null;
        _emailSaved  = false;
        StateHasChanged();  // show spinner first

        try
        {
            await AccountApi.RequestEmailChangeAsync(_newEmail);

            // Only update UI AFTER server confirms
            _emailSaved = true;
            _newEmail   = "";
        }
        catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
        {
            _emailError = "This email is already registered to another account.";
        }
        catch
        {
            _emailError = "Something went wrong. Please try again.";
        }
        finally
        {
            _emailSaving = false;
            StateHasChanged();
        }
    }
}

@* Template *@
@if (_emailSaved)
{
    <div class="success-banner" role="status">
        Verification email sent. Check your inbox to confirm the change.
    </div>
}
else
{
    <FormField Label="New Email Address"
               For="@(() => _newEmail)"
               Type="email"
               Required="true" />

    <button type="button"
            @onclick="ChangeEmail"
            disabled="@(_emailSaving || string.IsNullOrEmpty(_newEmail))">
        @(_emailSaving ? "Sending…" : "Change Email")
    </button>

    @if (_emailError is not null)
    {
        <p class="field-error" role="alert">@_emailError</p>
    }
}
Disable the Button, Not the Form

During an async save, disable the submit button (and any destructive buttons) rather than the entire <fieldset>. Disabling the fieldset breaks screen reader navigation and hides the form's current state from users who rely on reviewing fields during the wait. The button's disabled state alone communicates "action in progress" clearly enough.

End-to-End: Complete Profile Settings Page

All patterns assembled into a complete ProfileSettings.razor. Every section is active: FluentValidation, server error mapping, async username check, dirty state, save bar, navigation guard, and optimistic toggles.

Pages/ProfileSettings.razor — Complete
@page "/settings/profile"
@rendermode InteractiveServer
@using Blazored.FluentValidation
@implements IDisposable

@inject IProfileApi     ProfileApi
@inject IUsernameApi    UsernameApi
@inject IJSRuntime      JS
@inject NavigationManager Nav

<PageTitle>Profile Settings</PageTitle>

@* Accessible status announcer *@
<div class="sr-only" aria-live="polite" aria-atomic="true">@_statusMessage</div>

@if (_loading)
{
    <p>Loading profile…</p>
}
else
{
    <EditForm id="profile-form" EditContext="@_editContext" OnValidSubmit="HandleValidSubmit"
              OnInvalidSubmit="HandleInvalidSubmit">

        <DataAnnotationsValidator />
        <FluentValidationValidator DisableAssemblyScanning="false" />

        @if (_hasSubmitErrors)
        {
            <div class="validation-summary-container" role="alert" aria-live="assertive">
                <p>Please fix @_errorCount @(_errorCount == 1 ? "error" : "errors"):</p>
                <ValidationSummary />
            </div>
        }

        <section class="settings-section">
            <h2>Basic Information</h2>

            <!-- Username with async availability check -->
            <div class="field-with-status">
                <FormField Label="Username" For="@(() => _model.Username)"
                           Required="true" @oninput="OnUsernameInput"
                           autocomplete="username" />
                @if (_checkingUsername)
                {
                    <span class="field-status" aria-live="polite">Checking…</span>
                }
                else if (_usernameAvailable.HasValue)
                {
                    <span class="field-status @(_usernameAvailable.Value ? "ok" : "error")" aria-live="polite">
                        @(_usernameAvailable.Value ? "✓ Available" : "✗ Taken")
                    </span>
                }
            </div>

            <FormField Label="Display Name" For="@(() => _model.DisplayName)"
                       HelpText="How your name appears to others" />
            <FormField Label="Bio"          For="@(() => _model.Bio)"           Type="textarea" />
            <FormField Label="Date of Birth" For="@(() => _model.DateOfBirth)"  Type="date" />
            <FormSelect Label="Country"     For="@(() => _model.Country)">
                <option value="">Select…</option>
                <option value="US">United States</option>
                <option value="GB">United Kingdom</option>
                <option value="AU">Australia</option>
            </FormSelect>
        </section>

        <section class="settings-section">
            <h2>Preferences</h2>

            <!-- Optimistic theme toggle *@
            <fieldset disabled="@_themeSaving">
                <legend>Theme</legend>
                @foreach (var t in new[] { "light", "dark", "system" })
                {
                    <label>
                        <input type="radio" name="theme" value="@t"
                               checked="@(_model.Theme == t)"
                               @onchange="() => OnThemeChange(t)" />
                        @t
                    </label>
                }
            </fieldset>

            <label>
                <InputCheckbox @bind-Value="_model.EmailNotifications" />
                Email me about account activity
            </label>
        </section>

    </EditForm>

    <SaveBar IsDirty="@_isDirty"
             IsSaving="@_isSaving"
             FormId="profile-form"
             OnDiscard="DiscardChanges" />
}

@code {
    private ProfileModel _model        = new();
    private ProfileModel _originalModel = new();
    private EditContext? _editContext;
    private ValidationMessageStore? _serverErrors;

    private bool    _loading;
    private bool    _isSaving;
    private bool    _isDirty;
    private bool    _hasSubmitErrors;
    private int     _errorCount;
    private string  _statusMessage = "";

    // Username async check state
    private CancellationTokenSource? _usernameCts;
    private bool  _checkingUsername;
    private bool? _usernameAvailable;

    // Theme optimistic state
    private bool   _themeSaving;
    private string _savedTheme = "";

    // Navigation guard
    private IDisposable? _navRegistration;

    protected override async Task OnInitializedAsync()
    {
        _loading = true;
        _model   = await ProfileApi.GetCurrentAsync();
        _originalModel = _model with { };

        _editContext  = new EditContext(_model);
        _serverErrors = new ValidationMessageStore(_editContext);

        _editContext.OnFieldChanged += (_, args) =>
        {
            _serverErrors.Clear(args.FieldIdentifier);
            _editContext.NotifyValidationStateChanged();
            _isDirty = HasModelChanged();
            StateHasChanged();
        };

        _navRegistration = Nav.RegisterLocationChangingHandler(OnLocationChanging);
        _loading = false;
    }

    private bool HasModelChanged() =>
        _model.Username           != _originalModel.Username    ||
        _model.DisplayName        != _originalModel.DisplayName ||
        _model.Bio                != _originalModel.Bio         ||
        _model.Country            != _originalModel.Country     ||
        _model.DateOfBirth        != _originalModel.DateOfBirth ||
        _model.EmailNotifications != _originalModel.EmailNotifications;

    private async Task HandleValidSubmit()
    {
        _isSaving      = true;
        _hasSubmitErrors = false;
        _serverErrors!.Clear();
        _editContext!.NotifyValidationStateChanged();

        try
        {
            await ProfileApi.SaveAsync(_model);
            _originalModel = _model with { };
            _isDirty       = false;
            _statusMessage = "Profile saved successfully.";
        }
        catch (ApiValidationException ex)
        {
            foreach (var (field, msgs) in ex.Errors)
            {
                var fid = _editContext!.Field(field);
                foreach (var m in msgs) _serverErrors!.Add(fid, m);
            }
            _editContext!.NotifyValidationStateChanged();
        }
        finally
        {
            _isSaving = false;
        }
    }

    private async Task HandleInvalidSubmit()
    {
        _hasSubmitErrors = true;
        _errorCount      = _editContext!.GetValidationMessages().Count();
        _statusMessage   = $"Form has {_errorCount} errors. Please review.";
        await JS.InvokeVoidAsync("focusFirstError");
    }

    private async Task OnUsernameInput(ChangeEventArgs e)
    {
        _model.Username = e.Value?.ToString() ?? "";
        _usernameCts?.Cancel();
        _usernameCts?.Dispose();
        _usernameCts = new CancellationTokenSource();
        var token = _usernameCts.Token;
        _usernameAvailable = null;
        if (_model.Username.Length < 3) return;
        _checkingUsername = true;
        StateHasChanged();
        try
        {
            await Task.Delay(350, token);
            var available = await UsernameApi.IsAvailableAsync(_model.Username, token);
            _usernameAvailable = available;
            var fid = _editContext!.Field(nameof(ProfileModel.Username));
            _serverErrors!.Clear(fid);
            if (!available) _serverErrors.Add(fid, "This username is already taken.");
            _editContext.NotifyValidationStateChanged();
        }
        catch (OperationCanceledException) { }
        finally { _checkingUsername = false; StateHasChanged(); }
    }

    private async Task OnThemeChange(string newTheme)
    {
        _savedTheme  = _model.Theme;
        _model.Theme = newTheme;
        _themeSaving = true;
        StateHasChanged();
        try   { await ProfileApi.SetThemeAsync(newTheme); }
        catch { _model.Theme = _savedTheme; _statusMessage = "Couldn't save theme. Try again."; }
        finally { _themeSaving = false; StateHasChanged(); }
    }

    private void DiscardChanges()
    {
        _model       = _originalModel with { };
        _isDirty     = false;
        _editContext = new EditContext(_model);
        _serverErrors = new ValidationMessageStore(_editContext);
        _editContext.OnFieldChanged += (_, _) => { _isDirty = HasModelChanged(); StateHasChanged(); };
        StateHasChanged();
    }

    private async ValueTask OnLocationChanging(LocationChangingContext ctx)
    {
        if (!_isDirty) return;
        var ok = await JS.InvokeAsync<bool>("confirm", "Leave without saving?");
        if (!ok) ctx.PreventNavigation();
    }

    public void Dispose()
    {
        _navRegistration?.Dispose();
        _usernameCts?.Dispose();
    }
}

Component Unit Test

ProfileValidator Unit Test
public class ProfileValidatorTests
{
    private readonly ProfileValidator _validator = new();

    [Fact]
    public void Username_TooShort_Fails()
    {
        var model  = new ProfileModel { Username = "ab", Email = "a@b.com" };
        var result = _validator.Validate(model);
        Assert.False(result.IsValid);
        Assert.Contains(result.Errors, e => e.PropertyName == "Username");
    }

    [Fact]
    public void DisplayName_SameAsUsername_Fails()
    {
        var model  = new ProfileModel { Username = "john", DisplayName = "john", Email = "a@b.com" };
        var result = _validator.Validate(model);
        Assert.False(result.IsValid);
        Assert.Contains(result.Errors,
            e => e.PropertyName == "DisplayName" &&
                 e.ErrorMessage.Contains("same as your username"));
    }

    [Fact]
    public void DateOfBirth_Under13_Fails()
    {
        var model = new ProfileModel
        {
            Username    = "validuser",
            Email       = "a@b.com",
            DateOfBirth = DateTime.Today.AddYears(-10)
        };
        var result = _validator.Validate(model);
        Assert.False(result.IsValid);
        Assert.Contains(result.Errors, e => e.ErrorMessage.Contains("13"));
    }

    [Fact]
    public void Valid_Model_Passes()
    {
        var model = new ProfileModel
        {
            Username    = "john_doe",
            Email       = "john@example.com",
            DisplayName = "John Doe",
            DateOfBirth = DateTime.Today.AddYears(-25)
        };
        var result = _validator.Validate(model);
        Assert.True(result.IsValid);
    }
}

Resources & Next Steps

You've built a Profile Settings page that handles every real-world form complexity. Every component here is extracted and reused across any Blazor form in the same project without modification.

Official Documentation

Next Steps

Add file upload to the profile image field — Blazor's InputFile with client-side size/type validation and a progress indicator. Integrate the form with ASP.NET Core Identity for real user management. Add localization so validation messages appear in the user's language using IStringLocalizer. Move the profile form to WebAssembly render mode and adapt the async validation to work without the SignalR connection.

The reusable components — FormField, FormSelect, SaveBar — are the most transferable takeaways. Build a component library from them and every future form in your app inherits correct accessibility, error display, and dirty-state behavior for free.

Frequently Asked Questions

Should I use DataAnnotations or FluentValidation in Blazor?

Both work, but they serve different scales. DataAnnotations are fine for simple models — they're concise and built in. FluentValidation shines when rules get complex: conditional validation, cross-field rules, async checks, and validators you can unit-test in isolation without an HTTP context. Many teams start with DataAnnotations and migrate specific validators to FluentValidation when the rules outgrow attributes.

How do I show server-side API errors in Blazor form fields?

Add the errors programmatically to the EditContext's ValidationMessageStore. Call editContext.NotifyValidationStateChanged() after populating the store. Field-level messages appear under the correct input via ValidationMessage components. For API errors that return RFC-7807 ProblemDetails with an errors dictionary, map each key to the matching field name and add the messages to the store as shown in Section 6.

What is dirty state detection and why does it matter for UX?

Dirty state tracks whether the user has changed any field since the form loaded. It matters because showing a save button before anything has changed — or navigating away without warning — creates poor experiences. A sticky save bar that activates only when the form is dirty, and a navigation guard that warns on unsaved changes, are the two most impactful UX improvements for a settings form.

How do I debounce async validation in Blazor to avoid hammering the server?

Track a CancellationTokenSource per field. When the input changes, cancel the previous token and create a new one, then delay with Task.Delay(350, token) before triggering the API call. If the user types again within 350ms the previous check is cancelled. This keeps server load proportional to deliberate pauses in typing, not keystrokes.

How do I make Blazor form errors accessible to screen readers?

Three things matter: link every input to its error message with aria-describedby using matching IDs, mark inputs as aria-invalid="true" when they have errors, and use a live region (role="alert" or aria-live="polite") for the validation summary so screen readers announce it without focus moving. The default ValidationSummary component doesn't add these automatically — use a custom wrapper or explicit ARIA attributes on your input components.

What is optimistic UI and when should I use it in Blazor forms?

Optimistic UI assumes the save will succeed and updates the UI immediately, rolling back if it fails. Use it for low-stakes, high-frequency updates like toggling a theme or preference — the perceived speed improvement is significant. Avoid it for critical fields like email or password changes where a failed silent rollback would confuse the user. Always show a visible notification when an optimistic update fails.

Back to Tutorials