Skip to main content
ANVISoftware Solutions
Lesson 6 of 14Intermediate20 min

Forms and Validation

By the end of this lesson

Bind a form to a model and show validation messages.

A form in Blazor is an EditForm wrapped around a model object. The model is a plain C# class. The validation rules are attributes on its properties. The form reads and writes that object directly, so there is no code collecting values out of inputs.

That arrangement is what makes the next part possible: the same annotated class can be used by the server that stores the employee, so a rule such as a required name is written once rather than twice.

Models/EmployeeEdit.cs
C#
using System.ComponentModel.DataAnnotations;

public class EmployeeEdit
{
    [Required(ErrorMessage = "Enter the employee's full name.")]
    [StringLength(80, ErrorMessage = "Use 80 characters or fewer for the name.")]
    public string Name { get; set; } = string.Empty;

    [Required(ErrorMessage = "Enter a work email address.")]
    [EmailAddress(ErrorMessage = "Enter an email address in the form name@company.com.")]
    public string Email { get; set; } = string.Empty;

    [Required(ErrorMessage = "Choose a department.")]
    public string Department { get; set; } = string.Empty;

    [Range(20000, 200000, ErrorMessage = "Enter a salary between 20,000 and 200,000.")]
    public decimal? Salary { get; set; }
}
  • The attributes are the rules, and they sit on the model rather than in the markup or in a submit handler. Anything that can read this class can enforce them.
  • Every message is written by hand. The generated default names the property, which reads like a database error rather than an instruction to a person.
  • A message should say what to do. Enter a work email address is more use than Email is invalid, and it costs nothing extra.
  • Salary is decimal? rather than decimal. A non-nullable number is always some value, so an empty box would silently become zero and pass. Making it nullable lets Range and Required behave the way a reader expects.
  • There is nothing Blazor-specific in this file. It is a class with annotations, which is the point the shared-model section returns to.
Components/Employees/EmployeeForm.razor
C#
<EditForm Model="Model" OnValidSubmit="Save" FormName="employee-edit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div class="field">
        <label for="name">Full name</label>
        <InputText id="name" @bind-Value="Model.Name" />
        <ValidationMessage For="@(() => Model.Name)" />
    </div>

    <div class="field">
        <label for="email">Work email</label>
        <InputText id="email" type="email" @bind-Value="Model.Email" />
        <ValidationMessage For="@(() => Model.Email)" />
    </div>

    <div class="field">
        <label for="salary">Annual salary</label>
        <InputNumber id="salary" @bind-Value="Model.Salary" />
        <ValidationMessage For="@(() => Model.Salary)" />
    </div>

    <button type="submit">Save employee</button>
</EditForm>

@code {
    [Parameter, EditorRequired]
    public EmployeeEdit Model { get; set; } = default!;

    [Parameter]
    public EventCallback<EmployeeEdit> OnSaved { get; set; }

    private Task Save() => OnSaved.InvokeAsync(Model);
}
  • EditForm builds an edit context around Model. The context tracks which fields have been touched and which messages currently apply, and the validation components all talk to it rather than to each other.
  • OnValidSubmit runs only when validation passed. OnSubmit runs on every attempt and leaves the check to you, which is occasionally what you want and usually a way to forget it.
  • DataAnnotationsValidator is the component that reads the attributes. Leave it out and the form submits with an empty name, with no message and nothing in the console. This catches nearly everyone once.
  • ValidationSummary lists every current message in one block; ValidationMessage shows the messages for one field, beside that field. Use both — the summary helps someone who cannot see the whole form at once, the inline message helps the person fixing that field.
  • InputText and InputNumber report changes into the edit context, which is how a message can appear as soon as a field is left. A plain input element with @bind still updates the model, but the context does not hear about the edit, so its message waits for the submit.
  • @bind-Value is the two-way binding form across a component boundary: these components expose Value and ValueChanged, and one directive wires both.
  • For="@(() => Model.Name)" identifies the field with an expression rather than a string, so renaming the property is a compiler error instead of a message that silently stops appearing.
  • FormName is required when a form is rendered without an interactive render mode, because the server has to know which form on the page was posted. It does no harm when the component is interactive.
  • Each label's for matches the id given to the input component, which is why the ids are set explicitly. Without that pairing, clicking the label does nothing and a screen reader reads an unlabelled field.

The pieces, and what each one is responsible for:

EditForm
Wraps the fields and holds the edit context: the model, which fields have changed, and the messages that currently apply. It renders a form element, so Enter submits as usual.
DataAnnotationsValidator
Reads the attributes on the model and turns failures into messages in the context. Without this component present, nothing is validated.
ValidationSummary
Every current message in one block, usually at the top of the form.
ValidationMessage
The messages for one field, rendered where the reader is already looking.
OnValidSubmit, OnInvalidSubmit, OnSubmit
Three ways to handle a submit: only when valid, only when not, or always with the check left to you. Use one of them, not two.
Input components
InputText, InputTextArea, InputNumber, InputDate, InputSelect, InputCheckbox and InputRadioGroup bind to a model property and report into the edit context.

Here is the part that is genuinely different from a separate JavaScript front end. EmployeeEdit is a plain C# class. Put it in a project that both the Blazor components and the API reference, and the annotations that produce the on-screen messages are the same annotations the server checks when it receives the object. The rule exists once, in one file, and there is no way for the two ends to disagree about it.

With a separate front end, that rule is written twice: once in the client's validation library and once on the server. Two definitions drift. Somebody raises the maximum name length on the server, the client keeps rejecting 90 characters, and the report of the bug says the application will not save.

The cost is a shared project, and a shared project is coupling. The client and the API now release together for that model, and a team that needs to deploy them independently will often decide that duplicating a handful of rules is the cheaper problem. Be honest about which situation you are in rather than treating the shared model as automatically correct.

Annotations also cannot express every rule. Checking that an email address is not already used by another employee needs data, so it belongs on the server and comes back as an error you display. The shared model covers the shape of the data, not every decision about it.

Summary

  • An EditForm wraps a model object and holds an edit context that the validation components read and write
  • Validation rules are annotations on the model, and DataAnnotationsValidator is the component that applies them
  • ValidationSummary collects messages in one block and ValidationMessage shows them beside the field, which serve different readers
  • The same annotated class can be used by the server, so a rule is defined once instead of written twice — at the cost of a shared project and the coupling that brings
  • Client-side validation is feedback only; the server must enforce the rules, and rules needing data belong there anyway

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

A start date that cannot be in the future

Add a StartDate property to EmployeeEdit, show it with InputDate, and make a date after today produce a validation message beside the field.

There is no built-in attribute for this. Where should the rule live, and why does that choice matter more than how you write the check?

Show solution

Write a small ValidationAttribute and put it on the property. That keeps the rule on the model, which means the server enforces it too — the reason the shared model exists in the first place.

The alternative, checking the date inside the submit handler, works on screen and leaves the server unprotected. It also puts one rule somewhere different from all the others, so the next person reading the model believes they can see every constraint.

Returning the member name in the ValidationResult is what lets ValidationMessage show the message beside that field. Omit it and the message appears only in the summary, which readers scanning the field will miss.

C#
public sealed class NotInTheFutureAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext context)
    {
        if (value is DateOnly date && date > DateOnly.FromDateTime(DateTime.Today))
        {
            return new ValidationResult(
                "The start date cannot be in the future.",
                new[] { context.MemberName! });
        }

        return ValidationResult.Success;
    }
}

// On the model
[Required(ErrorMessage = "Enter the date the employee started.")]
[NotInTheFuture]
public DateOnly? StartDate { get; set; }

Think about it

Where does the uniqueness rule live?

No two employees may share a work email address.

Why can that not be an annotation on EmployeeEdit, where does the check belong, and how should the result reach the person filling in the form?

Show solution

The rule needs data. Deciding whether an address is already in use means asking the store of employees, and an attribute on a model has no business holding a database dependency. Annotations describe the shape of a value, not its relationship to everything else.

So the check belongs on the server, in the code that saves. The API rejects the save and returns which field was wrong and why, and the form displays that alongside the annotation messages rather than in a separate place the reader has to learn about.

In Blazor you can push a server message into the form's context through a validation message store, so it appears next to the field like any other. If you show it as a general error instead, give that region role="alert" so it is announced when it appears — a message that only looks different is a message some readers never receive.

There is a design decision worth naming: you can also check as the reader leaves the email field, which is kinder, but it is an extra call and it is still not enforcement. The save-time check on the server remains the one that counts.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A form has annotations on its model, OnValidSubmit wired up, and a ValidationMessage beside each field. Invalid data still saves and no message appears. What is most likely missing?
The same annotated model runs in the browser and on the server. Why keep the server check?

Saved in this browser only.