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.
@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>:
<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.
@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.
@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.
@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.
// ── 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.