Blazor Form UX: Inline Errors, Async Field Validation, Dirty State Detection & Optimistic UI

The Gap Between Working and Feeling Good

A Blazor form that submits correctly and a Blazor form that feels good to use are not the same thing. The difference is in the details: errors that appear exactly when expected (not before, not never), fields that check availability without hammering your server on every keystroke, a save button that appears only when something has actually changed, and toggles that respond instantly without making the user wait for a round trip.

None of these patterns are complex. They each involve a small amount of EditContext plumbing that most tutorials skip because it doesn't fit in a getting-started example. This article covers all four — with working Blazor component code you can adapt and ship.

Each pattern is self-contained. You don't need to implement all four to benefit from any one of them. Pick the ones your current form is missing.

Inline Errors on Blur (Not on Load)

The default Blazor validation behaviour shows errors on submit. Better UX shows errors per field the moment the user leaves it — but not before they've touched it. A form that opens showing every required field in red before the user has typed anything is aggressively hostile.

The fix is a touched-fields tracker built on top of EditContext.OnFieldChanged. Fields only show errors once they appear in the touched set.

Components/SmartValidationMessage.razor
@typeparam TValue
@inject EditContext EditCtx

@* Renders field error only after the field has been touched (left at least once) *@
@if (_isTouched && _errors.Any())
{
    <ul class="field-errors" role="alert" aria-live="polite">
        @foreach (var err in _errors)
        {
            <li>@err</li>
        }
    </ul>
}

@code {
    [Parameter, EditorRequired]
    public Expression<Func<TValue>> For { get; set; } = default!;

    private FieldIdentifier _fieldId;
    private bool            _isTouched;
    private IEnumerable<string> _errors = [];

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

        // Subscribe — fires whenever any field in the form changes
        EditCtx.OnFieldChanged      += OnFieldChanged;
        EditCtx.OnValidationStateChanged += OnValidationStateChanged;
    }

    private void OnFieldChanged(object? sender, FieldChangedEventArgs e)
    {
        // Mark this field as touched the first time it changes
        if (e.FieldIdentifier.Equals(_fieldId))
            _isTouched = true;

        RefreshErrors();
        StateHasChanged();
    }

    private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e)
    {
        RefreshErrors();
        StateHasChanged();
    }

    private void RefreshErrors() =>
        _errors = EditCtx.GetValidationMessages(_fieldId);

    public void Dispose()
    {
        EditCtx.OnFieldChanged           -= OnFieldChanged;
        EditCtx.OnValidationStateChanged -= OnValidationStateChanged;
    }
}

Use it inside any EditForm in place of the standard <ValidationMessage>:

Components/Pages/RegistrationForm.razor — Usage
<EditForm Model="model" OnValidSubmit="HandleSubmit">
    <DataAnnotationsValidator />

    <div class="form-field">
        <label for="email">Email address</label>
        <InputText id="email" @bind-Value="model.Email" />

        @* Only shows error after the user has left this field at least once *@
        <SmartValidationMessage For="() => model.Email" />
    </div>

    <div class="form-field">
        <label for="password">Password</label>
        <InputText id="password" type="password" @bind-Value="model.Password" />
        <SmartValidationMessage For="() => model.Password" />
    </div>

    <button type="submit">Create Account</button>
</EditForm>

The aria-live="polite" attribute on the error list means screen readers announce errors as they appear without interrupting the user mid-sentence. Use assertive only for critical errors that require immediate attention — polite is the right default for field-level validation.

Async Field Validation with Debounce

Username availability, email uniqueness, discount code validation — these checks require an API call. Without debounce, a user typing "alexsmith" fires nine separate requests. With a 400ms debounce, it fires one: after they've stopped typing.

The pattern uses a CancellationTokenSource that gets cancelled and replaced on every keystroke. The API call only runs when the debounce timer completes without interruption. A stale result from a slow earlier request never overwrites a faster recent one.

Components/UsernameField.razor — Async Debounced Validation
@inject IUserService UserService

<div class="form-field">
    <label for="username">Username</label>
    <div class="input-with-status">
        <InputText id="username"
                   @bind-Value="Value"
                   @bind-Value:after="OnUsernameChanged" />

        @* Status indicator — shows checking / available / taken *@
        @if (_isChecking)
        {
            <span class="status-checking" aria-label="Checking availability">
                <span class="spinner" aria-hidden="true"></span> Checking...
            </span>
        }
        else if (_isAvailable == true)
        {
            <span class="status-ok" role="status">✓ Available</span>
        }
        else if (_isAvailable == false)
        {
            <span class="status-error" role="alert">✗ Already taken</span>
        }
    </div>
</div>

@code {
    [Parameter, EditorRequired]
    public string Value { get; set; } = "";

    [Parameter]
    public EventCallback<string> ValueChanged { get; set; }

    private bool?  _isAvailable;
    private bool   _isChecking;
    private CancellationTokenSource? _cts;

    private async Task OnUsernameChanged()
    {
        // Propagate binding change to parent
        await ValueChanged.InvokeAsync(Value);

        // Reset state for very short inputs — don't bother checking
        _isAvailable = null;
        if (string.IsNullOrWhiteSpace(Value) || Value.Length < 3)
        {
            _isChecking = false;
            return;
        }

        // Cancel any in-flight check from a previous keystroke
        _cts?.Cancel();
        _cts?.Dispose();
        _cts = new CancellationTokenSource();

        _isChecking = true;
        StateHasChanged();     // show spinner immediately

        try
        {
            // Debounce: wait 400ms before firing the API call
            await Task.Delay(400, _cts.Token);

            _isAvailable = await UserService.IsUsernameAvailableAsync(
                Value, _cts.Token);
        }
        catch (OperationCanceledException)
        {
            // A newer keystroke cancelled this check — silently discard
            return;
        }
        finally
        {
            _isChecking = false;
        }

        StateHasChanged();
    }

    public void Dispose()
    {
        _cts?.Cancel();
        _cts?.Dispose();
    }
}

The @bind-Value:after syntax (introduced in .NET 7) calls OnUsernameChanged after the binding update completes, which means Value already holds the new string when your async logic runs. Without :after, you'd be reading the previous value.

Dirty State Detection & the Sticky Save Bar

A save button that's always enabled is noise. A save button that only appears when something has actually changed communicates clearly: "there's unsaved work here." Combined with a sticky bar fixed at the bottom of the viewport, it follows the user as they scroll through a long settings form — they always know their changes are waiting to be saved.

True dirty detection requires a snapshot of the original model values. EditContext.IsModified() only tracks whether a field was touched, not whether the current value differs from what was originally loaded.

Components/Pages/UserSettings.razor — Dirty State + Sticky Bar
@inject IUserService UserService
@inject ISnackbar    Snackbar

<EditForm EditContext="_editContext" OnValidSubmit="SaveChanges">
    <DataAnnotationsValidator />

    <div class="settings-form">
        <div class="form-field">
            <label>Display name</label>
            <InputText @bind-Value="_model.DisplayName" />
            <SmartValidationMessage For="() => _model.DisplayName" />
        </div>

        <div class="form-field">
            <label>Bio</label>
            <InputTextArea @bind-Value="_model.Bio" rows="4" />
        </div>

        <div class="form-field">
            <label>Timezone</label>
            <InputSelect @bind-Value="_model.Timezone">
                @foreach (var tz in TimeZoneInfo.GetSystemTimeZones())
                {
                    <option value="@tz.Id">@tz.DisplayName</option>
                }
            </InputSelect>
        </div>
    </div>

    @* Sticky save bar — only visible when form is dirty *@
    @if (_isDirty)
    {
        <div class="sticky-save-bar" role="status"
             aria-label="Unsaved changes">
            <span class="unsaved-indicator">
                ● Unsaved changes
            </span>
            <div class="save-actions">
                <button type="button" class="btn-discard"
                        @onclick="DiscardChanges">
                    Discard
                </button>
                <button type="submit" class="btn-save"
                        disabled="@_isSaving">
                    @(_isSaving ? "Saving..." : "Save Changes")
                </button>
            </div>
        </div>
    }
</EditForm>

@code {
    private UserSettingsModel _model  = new();
    private UserSettingsModel _snapshot = new();   // original loaded values
    private EditContext        _editContext = default!;
    private bool _isDirty;
    private bool _isSaving;

    protected override async Task OnInitializedAsync()
    {
        var data = await UserService.GetSettingsAsync();
        _model   = data;
        _snapshot = data with { };   // value copy (record)

        _editContext = new EditContext(_model);
        _editContext.OnFieldChanged += (_, _) => CheckDirty();
    }

    private void CheckDirty()
    {
        // Compare current model to original snapshot field by field
        _isDirty =
            _model.DisplayName != _snapshot.DisplayName ||
            _model.Bio         != _snapshot.Bio         ||
            _model.Timezone    != _snapshot.Timezone;

        StateHasChanged();
    }

    private async Task SaveChanges()
    {
        _isSaving = true;
        try
        {
            await UserService.SaveSettingsAsync(_model);
            _snapshot = _model with { };   // update snapshot to saved values
            _isDirty  = false;
            Snackbar.Add("Settings saved.", Severity.Success);
        }
        finally
        {
            _isSaving = false;
        }
    }

    private void DiscardChanges()
    {
        // Reset model to snapshot values and clear dirty state
        _model = _snapshot with { };
        _editContext = new EditContext(_model);
        _editContext.OnFieldChanged += (_, _) => CheckDirty();
        _isDirty = false;
    }
}

The with { } expression on records creates a shallow copy — perfect for settings models where all fields are value types or immutable strings. For models with reference-type properties, implement a deep clone or use a JSON round-trip: JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(model)).

Optimistic UI for Instant Toggle Feedback

A notification preference toggle that waits for a server response before updating feels sluggish. A toggle that responds instantly and rolls back silently if the server fails feels fast. For low-stakes settings where failure is rare, optimistic UI is the right trade-off.

The pattern: update the UI immediately, fire the API call, and roll back to the previous state if it fails. Keep the pre-optimistic value in a local variable so the rollback is a single assignment.

Components/NotificationToggle.razor — Optimistic UI
@inject INotificationService NotificationService
@inject ISnackbar            Snackbar

<div class="notification-row">
    <div class="notification-label">
        <strong>@Label</strong>
        <span class="notification-description">@Description</span>
    </div>

    <button role="switch"
            aria-checked="@IsEnabled.ToString().ToLower()"
            aria-label="@Label"
            class="toggle @(IsEnabled ? "toggle--on" : "toggle--off")"
            @onclick="ToggleAsync"
            disabled="@_isToggling">
        <span class="toggle__thumb"></span>
    </button>
</div>

@code {
    [Parameter, EditorRequired] public string  Label       { get; set; } = "";
    [Parameter, EditorRequired] public string  Description { get; set; } = "";
    [Parameter, EditorRequired] public string  SettingKey  { get; set; } = "";
    [Parameter]                 public bool    IsEnabled   { get; set; }
    [Parameter]                 public EventCallback<bool> IsEnabledChanged { get; set; }

    private bool _isToggling;

    private async Task ToggleAsync()
    {
        if (_isToggling) return;
        _isToggling = true;

        var previousValue = IsEnabled;

        // ── Optimistic update: flip the UI immediately ─────────────────
        IsEnabled = !IsEnabled;
        await IsEnabledChanged.InvokeAsync(IsEnabled);
        StateHasChanged();

        try
        {
            // ── Fire the real API call ────────────────────────────────
            await NotificationService.SetPreferenceAsync(SettingKey, IsEnabled);
        }
        catch
        {
            // ── Rollback: restore previous value on failure ───────────
            IsEnabled = previousValue;
            await IsEnabledChanged.InvokeAsync(IsEnabled);
            StateHasChanged();

            Snackbar.Add(
                $"Couldn't update '{Label}'. Please try again.",
                Severity.Error);
        }
        finally
        {
            _isToggling = false;
        }
    }
}

The role="switch" and aria-checked attributes tell assistive technologies this is a toggle, not a plain button. Screen readers will announce "Email digests, on" or "Email digests, off" when the toggle is activated — without these attributes they'd only announce "button" with no state context.

Which Pattern for Which Situation

These four patterns aren't mutually exclusive — a real settings page might use all of them simultaneously. But they each have a primary use case, and applying them indiscriminately adds complexity without proportional UX benefit.

Pattern Decision Guide
// ── INLINE ERRORS ON BLUR ──────────────────────────────────────────────────
// Use for: any form with required fields or format constraints
// Especially: registration, checkout, profile edit, any form users fill once
// Avoid: very short forms (1–2 fields) where submit-time errors are fine

// ── ASYNC DEBOUNCED VALIDATION ────────────────────────────────────────────
// Use for: uniqueness checks (username, email, code, slug)
// Debounce window: 300–500ms for typing fields; 0ms for blur-only checks
// Always show a loading indicator — "Checking..." reassures users it's working
// Always handle OperationCanceledException — it's expected, not an error

// ── DIRTY STATE + STICKY SAVE BAR ─────────────────────────────────────────
// Use for: settings pages, profile editors, multi-field configuration forms
// Use for: any form where the user might scroll down before saving
// Avoid: short forms where the submit button is always visible
// Snapshot strategy: record with {} for value types, JSON clone for graphs

// ── OPTIMISTIC UI ─────────────────────────────────────────────────────────
// Use for: toggles, checkboxes, star ratings, list item completion
// Use for: any idempotent action with a clear undo path
// Avoid: delete operations, financial changes, anything hard to reverse
// Always: implement rollback — optimistic without rollback is just a bug

// ── EXAMPLE: Account Settings Page (uses all four) ────────────────────────
// - Username field:       async debounced uniqueness check
// - All required fields:  inline errors on blur (SmartValidationMessage)
// - Save/discard:         dirty state detection + sticky bar
// - Notification prefs:   optimistic UI toggles per preference row

The patterns in this article are the building blocks. The full implementation — where these work together with FluentValidation, server-side error mapping from ProblemDetails, and accessible ARIA live regions — is covered in the hands-on tutorial below.

Quick Answers

How do I show validation errors only after a field is touched in Blazor?

Subscribe to EditContext.OnFieldChanged and track which fields have been modified in a HashSet<FieldIdentifier>. In your error display component, only render the error message if the field's FieldIdentifier is in the touched set. This prevents the "all red on load" problem where every required field shows an error before the user has interacted with the form.

Why does my Blazor async validation fire on every keystroke?

Because EditContext.OnFieldChanged fires on every @bind update, which happens on every input event. Add a debounce — cancel and reschedule the async check using a CancellationTokenSource each time the field changes. A 400ms window means the API call only fires after the user stops typing. Always cancel the previous token before creating a new one to avoid race conditions where a slow earlier response arrives after a faster later one.

What is the difference between IsDirty and IsModified in Blazor's EditContext?

EditContext.IsModified(fieldIdentifier) returns true if a field has been touched since the EditContext was created — it doesn't compare values. For true dirty detection (has the value actually changed from what was loaded?), capture a snapshot of the original model and compare field-by-field on each OnFieldChanged event. Use record with { } for a shallow copy of value-type models.

When should I use optimistic UI versus waiting for the server response?

Use optimistic UI for low-stakes, high-frequency toggles where failure is rare and easily reversible: notification preferences, feature flags, dark mode, task completion. Do not use it for destructive actions (delete, deactivate), financial state changes, or anything where showing incorrect state even briefly causes trust issues. Always implement a rollback path that restores the pre-optimistic state on failure, and surface the error clearly.

Back to Articles