Blazor Forms: EditForm and Validation
Objective
A Blazor form is three objects that people tend to see as one: the model (your
class), the EditContext (the bookkeeping: which fields changed, which messages
exist) and the components that read them (InputText, ValidationMessage,
DataAnnotationsValidator). Once you see that split, custom rules, server-side
errors and custom inputs stop being tricks. This concept runs a form in
Interactive Server on SDK 10.0.100 and logs the EditContext events to show what
validates when, then adds the three extension points (IValidatableObject and
custom attributes, a ValidationMessageStore, an InputBase<T> subclass) and
the .NET 10 change that lets data annotations validate nested objects and
collection items. Forms posted from static SSR pages are in Blazor Static SSR and
Enhanced Navigation, and the same annotations on the API side are in Input
Validation in Minimal APIs.
Use Cases
- Building a create or edit form with field-level messages and a summary.
- Rejecting a submit for a reason only the server knows (a taken user name).
- Validating an order with a customer and a list of items from one
EditForm. - Writing an input that parses a custom format, such as
25%.
Deep Dive
Model, EditContext and the validator
EditForm needs either Model or EditContext. With Model it creates the
context for you; with EditContext you own it and can subscribe to it. The
DataAnnotationsValidator component is what connects the two: it listens to the
context and runs the attributes on the model. Without it, EditForm only tracks
changes and nothing validates. A form on a Signup model ([Required],
[StringLength(10, MinimumLength = 3)], [Range(18, 120)] on an int,
[EmailAddress], and IValidatableObject), with a log on the context events,
produced this when a field was edited and then the form was submitted:
textOnFieldChanged Name modified=True OnValidationStateChanged messages=1 <- the field's attributes ran ... OnValidationRequested <- submit: the whole model is validated OnValidationStateChanged messages=2 invalid submit <- OnInvalidSubmit
The rules behind that output, all seen in the run:
- Editing a field validates that field's property attributes only. The
IValidatableObject.Validateruleadmin is reserved.did not show whenNamewas set toadmin. It appeared on submit and onctx.Validate(). - A submit raises
OnValidationRequested, thenOnValidSubmitorOnInvalidSubmit. UseOnSubmitinstead if you want to callValidate()yourself. modified invalidandmodified validCSS classes are added to the inputs, so styling needs no code.- Typing
abcin theInputNumber<int>gaveThe Age field must be a number.from the input component itself, not from an attribute, and the model kept the old value. A binding failure is a validation message too.
Blazor commits the value on the change event (when the input loses focus), not
on every keystroke, unless you bind with @bind-Value:event="oninput".
Nested objects and collections in .NET 10
Before .NET 10, DataAnnotationsValidator validated the properties of the model
object and stopped there: a Customer or a List<OrderItem> inside it was not
visited. The ASP.NET Core 10 release notes describe the new behavior and three
steps to opt in: call AddValidation() in Program.cs, declare the form model
types in a .cs file (not in a .razor file, because a source generator's
output cannot feed another generator), and put [ValidatableType] on the root
type. The other types are discovered from it.
csharp[ValidatableType]
public class Order
{
public Customer Customer { get; set; } = new();
public List<OrderItem> Items { get; set; } = [new(), new()];
}
public class Customer { [Required(ErrorMessage = "Email is required.")] public string? Email { get; set; } }
public class OrderItem { [Range(1, 10, ErrorMessage = "Quantity must be 1 to 10.")] public int Quantity { get; set; } }The same form, run twice. Submitting an order with no email and two quantities
of 0:
textwithout AddValidation : result = valid, no messages with AddValidation : result = invalid Email is required. Quantity must be 1 to 10. Quantity must be 1 to 10.
After the change, fixing the customer email in the field removed its message at
once and left the two quantity messages, so the nested fields validate per field
as well. Adding using Microsoft.Extensions.Validation; to the model file made
[ValidatableType] ambiguous (CS0104) in my project, so leave that using out.
The release notes also list [SkipValidation] to exclude a property or type,
a source-generated implementation (friendlier to trimming and AOT than
reflection), and the same ordering as Validator: properties first (recursing
into nested objects), then type-level attributes, then IValidatableObject, and
the later steps are skipped if an earlier one reports an error. For a model in a
library or the .Client project, the notes say to expose a method that calls
AddValidation on an IServiceCollection and call it from the app too.
Same engine as minimal APIs
AddValidation is the registration used for minimal APIs
(Microsoft.Extensions.Validation). In the same app, a minimal API that took the
same Order returned this with AddValidation on, and 200 with the invalid
body when it was off:
textHTTP 400 {"errors":{"Customer.Email":["Email is required."], "Items[0].Quantity":["Quantity must be 1 to 10."]}}
So one set of annotations serves the form and the endpoint, and the keys
(Customer.Email, Items[0].Quantity) are the paths the two sides use for the
messages. The difference worth remembering: a Blazor form validated top-level
properties without AddValidation, whereas a minimal API validated nothing
without it.
Custom rules, server errors and inputs
Three extension points, all run in one /custom page:
A custom attribute derived from
ValidationAttributereturning aValidationResultwithc.MemberName;root is banned.appeared on field change like any built-in rule.A
ValidationMessageStorefor errors the attributes cannot know. AfterSavefound the user nameanataken, the form added a message to that field and calledNotifyValidationStateChanged:csharpstore = new ValidationMessageStore(ctx); ctx.OnFieldChanged += (_, e) => { store.Clear(e.FieldIdentifier); ctx.NotifyValidationStateChanged(); }; // in Save: store.Add(ctx.Field(nameof(Form.UserName)), "ana is already taken."); // ctx.NotifyValidationStateChanged();The message showed after the submit and disappeared when the user edited the field, because the handler clears that field's messages; a store keeps its messages until you clear them. Submitting
bobthen saved.An
InputBase<T>subclass for a value that is not plain text. OverridingTryParseValueFromStringandFormatValueAsStringmade an input that showed10%for the integer10, accepted25%, and forabcreportedThe Discount field must look like 50%., marked the inputmodified invalid, and left the typed text in the box:csharpprotected override string? FormatValueAsString(int v) => v + "%"; protected override bool TryParseValueFromString(string? s, out int result, [NotNullWhen(false)] out string? error) { if (s is not null && s.EndsWith('%') && int.TryParse(s.AsSpan(0, s.Length - 1), out result)) { error = null; return true; } result = default; error = $"The {DisplayName ?? FieldIdentifier.FieldName} field must look like 50%."; return false; }
Trade-offs
DataAnnotationsValidatoris simple and declarative, with limited expressiveness. Rules that need I/O (uniqueness) or several fields at once belong inIValidatableObjector a message store, not in attributes.- Nested validation is an opt-in with three conditions. Forget the
[ValidatableType]or put the model in a.razorfile and nothing nested is validated, with no error to tell you.csharp[ValidatableType] // on the root form model only public class Order { ... } - Field-level and model-level rules fire at different times. A rule that appears only on submit surprises users who watch the page as they type; keep cross-field rules few.
- In Interactive Server every check runs on the server. Each field change is a message over the circuit, so no error appears before the round trip, and the form's rules do not replace the validation of an API behind it.
- A custom
InputBase<T>takes over parsing and formatting. It is the right place for a format, and an error message you write is yours to localize. - A shared annotation model is a coupling. The same class validating the form and the endpoint saves duplication, and it also means a change in one is a change in the other.