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
Accessibility — aria-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.
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.
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.");
}
}
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.
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(); };
}
}
@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.
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.
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.
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.