Mert Özen Further With Every Line
Frontend Blazor — #07

Forms and Validation in Blazor: EditForm, Input Components, DataAnnotations and .NET 10 Nested Validation

Mert Özen Nov 15, 2026 16 min 8 views
Forms and Validation in Blazor: EditForm, Input Components, DataAnnotations and .NET 10 Nested Validation

A Blazor form is built with EditForm and EditContext, and its rules sit on the model as DataAnnotations attributes; in .NET 10, AddValidation and [ValidatableType] also validate nested objects and lists. In the Bookstore panel we show a form that adds an author with their books, a custom attribute for the ISBN check digit, mapping the API's 400 errors to the right field with ValidationMessageStore, and when a Static SSR form is validated, with real output.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15); browser behaviour was observed with Playwright 1.65's headless Chromium, and the example in the .NET 11 section was run with the .NET 11 SDK 11.0.100.

Predict First

In this post we add a form to the admin panel of Bookstore, the online bookstore used as the shared example across this site's series: saving a new author together with their books. The form's model is an author with a list of books under it; the ISBN, price and year rules are on the books. The form is InteractiveServer and uses <DataAnnotationsValidator />. When you submit the empty form, how many error messages do you see: just one for the author's name, or also some for the book's fields? Second question: when you enter Dune's ISBN, a number already in the database, who catches the error and where does the message appear on the screen? Third: if you add [Range(0, 10_000)] to the Static SSR stock count form from 05, does the message appear right away when the user types -5 and leaves the field?

EditForm, EditContext and the Input Components

At the centre of a Blazor form is EditForm. You give it either a Model or an EditContext, not both. EditContext holds the form's state: which field changed, which field has which message, whether the form is currently valid. When you give a Model, EditForm creates it itself; if you need to do something like adding errors from the server, you have to create it yourself. The markup of the new author page:

@page "/authors/new"
@rendermode InteractiveServer
@using System.Text.RegularExpressions
@using Bookstore.Admin.Components.Catalog
@using Bookstore.Admin.Validation
@inject BookstoreApiClient Api
@inject NavigationManager Navigation

<PageTitle>New author</PageTitle>

<h1>New author</h1>

<EditForm EditContext="editContext" OnValidSubmit="SaveAsync">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div class="mb-3">
        <label class="form-label">Name <InputText @bind-Value="form.Name" class="form-control" /></label>
        <ValidationMessage For="() => form.Name" />
    </div>

    @for (int i = 0; i < form.Books.Count; i++)
    {
        NewBookForm book = form.Books[i];
        <fieldset class="border rounded p-2 mb-2">
            <legend class="fs-6">Book @(i + 1)</legend>
            <label>ISBN <InputText @bind-Value="book.Isbn" class="form-control" /></label>
            <ValidationMessage For="() => book.Isbn" />
            <label>Title <InputText @bind-Value="book.Title" class="form-control" /></label>
            <ValidationMessage For="() => book.Title" />
            <label>Price (TRY) <InputNumber @bind-Value="book.Price" class="form-control" /></label>
            <ValidationMessage For="() => book.Price" />
            <label>Year <InputNumber @bind-Value="book.PublishedYear" class="form-control" /></label>
            <ValidationMessage For="() => book.PublishedYear" />
            <label>Category
                <InputSelect @bind-Value="book.CategorySlug" class="form-select">
                    <option value="">(choose)</option>
                    @foreach (CategoryView category in categories)
                    {
                        <option value="@category.Slug">@category.Name</option>
                    }
                </InputSelect>
            </label>
            <ValidationMessage For="() => book.CategorySlug" />
            <button type="button" class="btn btn-sm btn-outline-danger" disabled="@(form.Books.Count == 1)"
                    @onclick="() => form.Books.Remove(book)">Remove</button>
        </fieldset>
    }

    <button type="button" class="btn btn-outline-secondary" @onclick="() => form.Books.Add(new())">Add another book</button>
    <button type="submit" class="btn btn-primary" disabled="@saving">Save</button>
</EditForm>

The input components bind HTML inputs to the model and do the type conversion: InputText for strings, InputNumber for numbers, InputSelect for a select list; there are also InputDate, InputCheckbox, InputRadioGroup, InputTextArea, InputFile, and InputHidden, new in .NET 10, which carries string values in a hidden field. These components notify the EditContext when a field changes and add the invalid CSS class to an invalid field. ValidationMessage shows the messages of one field, ValidationSummary shows all of them. The For="() => book.Isbn" lambda is an expression; Blazor works out from it which property of which object the field is. That is why the NewBookForm book = form.Books[i]; line in the loop matters: had you written the lambda with form.Books[i], i would be left at its last value when the loop ends and every field would look at the wrong book.

Because the form is interactive, the "Add another book" and "Remove" buttons are ordinary @onclick handlers: they add items to and remove items from the list, and Blazor renders again. In Static SSR the same thing would mean a POST on every click. There are three options for submission: OnValidSubmit runs only when the form is valid, OnInvalidSubmit only when it is invalid; OnSubmit leaves validation entirely to you. Here I used OnValidSubmit; no request goes to the API until the rules pass.

The Form Model: DataAnnotations, a Custom Attribute and IValidatableObject

The rules sit on the model as attributes:

using System.ComponentModel.DataAnnotations;
using Bookstore.Admin.Validation;

namespace Bookstore.Admin.Components.Catalog;

// The root of the form's validation graph; NewBookForm is reached through Books.
[ValidatableType]
public sealed class NewAuthorForm : IValidatableObject
{
    [Required, StringLength(200)]
    public string Name { get; set; } = "";

    [MinLength(1, ErrorMessage = "Add at least one book.")]
    public List<NewBookForm> Books { get; set; } = [new()];

    // Runs after every property rule has passed.
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        IEnumerable<string> duplicates = Books
            .GroupBy(b => Isbn13Attribute.Normalize(b.Isbn))
            .Where(g => g.Count() > 1)
            .Select(g => g.Key);
        foreach (string isbn in duplicates)
        {
            yield return new ValidationResult($"ISBN {isbn} appears more than once in this form.");
        }
    }
}

public sealed class NewBookForm
{
    [Required, Isbn13, Display(Name = "ISBN")]
    public string Isbn { get; set; } = "";

    [Required, StringLength(200)]
    public string Title { get; set; } = "";

    [Range(typeof(decimal), "1", "10000", ErrorMessage = "The price must be between 1 and 10,000 TRY.")]
    public decimal Price { get; set; }

    [Range(1450, 2100), Display(Name = "Year")]
    public int PublishedYear { get; set; }

    [Required(ErrorMessage = "Choose a category.")]
    public string CategorySlug { get; set; } = "";
}

[Required], [StringLength], [Range] and [MinLength] are the familiar DataAnnotations attributes, the same infrastructure we saw in the validation post of the ASP.NET Core MVC and Razor Pages series. For decimal you have to write Range(typeof(decimal), "1", "10000"), because a decimal literal can't be passed as an attribute argument. [Display(Name = "ISBN")] changes the field name in the messages; without it the user sees property names, as in "The field PublishedYear must be...".

An ISBN being 13 digits is not enough; the last digit is a check digit. For that I wrote a small custom attribute:

using System.ComponentModel.DataAnnotations;

namespace Bookstore.Admin.Validation;

// ISBN-13: 13 digits (hyphens and spaces allowed) whose weighted sum (1, 3, 1, 3, ...) is a multiple of 10.
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public sealed class Isbn13Attribute() : ValidationAttribute("{0} is not a valid ISBN-13.")
{
    public override bool IsValid(object? value)
    {
        if (value is not string text || text.Length == 0)
        {
            return true; // [Required] decides whether an empty value is allowed
        }

        string digits = Normalize(text);
        if (digits.Length != 13 || !digits.All(char.IsAsciiDigit))
        {
            return false;
        }

        int sum = 0;
        for (int i = 0; i < 13; i++)
        {
            sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
        }
        return sum % 10 == 0;
    }

    public static string Normalize(string isbn) => isbn.Replace("-", "").Replace(" ", "");
}

Returning true for an empty value is deliberate: being mandatory is [Required]'s job, so two attributes don't produce two messages for the same field. For rules that are not tied to a single field there is IValidatableObject. Entering the same ISBN twice in one form is not a book's error but the form's; the Validate method returns it as a model-level message. The validation order is: property attributes first, then type-level attributes, Validate last; if a step has errors, the later ones don't run. So the user sees the duplicate-ISBN message only once all the fields are valid.

.NET 10 Nested Validation: AddValidation and [ValidatableType]

Up to .NET 9, DataAnnotationsValidator only validated the model's own properties. The rules of the books in the Books list would not run; you needed ObjectGraphDataAnnotationsValidator from an experimental package or hand-written code. In .NET 10 the Microsoft.Extensions.Validation infrastructure, which Minimal APIs use too, came to Blazor. Three things are needed: one line in Program.cs,

// Source-generated validation metadata: nested objects and collections in [ValidatableType] form models.
builder.Services.AddValidation();

[ValidatableType] on the root model, and the models living in a .cs file rather than a .razor file. AddValidation brings in a source generator that runs at build time; starting from the type marked with [ValidatableType], the generator produces validation metadata for every type it can reach. It can't see a class inside a .razor file, which is why the models are in a separate file. When I submitted the empty form with Playwright:

1) empty form: 6 field messages
   - The Name field is required.
   - The ISBN field is required.
   - The Title field is required.
   - The price must be between 1 and 10,000 TRY.
   - The field Year must be between 1450 and 2100.
   - Choose a category.

The answer to the first question: six messages, one for the author's name and five for the book's fields. When I removed the AddValidation() line and repeated the test:

empty form: 1 field message(s)
   - The Name field is required.

No build error, no warning; the book fields are silently not validated. If you forget this line, you only see the error when the API rejects the request. The documentation has one more warning: ValidatableTypeAttribute is an experimental API in .NET 10 and gives warning ASP0029 when used directly. In web projects that contain Razor components, the SDK generates a non-experimental copy in the Microsoft.Extensions.Validation.Embedded namespace and adds it with a global using; that is why [ValidatableType] works without an extra using. In my first attempt I added using Microsoft.Extensions.Validation; to the file and the build stopped with this error:

src/Bookstore.Admin/Components/Catalog/NewAuthorForm.cs(8,2): error CS0104: 'ValidatableType' is an ambiguous reference between 'Microsoft.Extensions.Validation.Embedded.ValidatableTypeAttribute' and 'Microsoft.Extensions.Validation.ValidatableTypeAttribute'

The fix is to delete that using. The validation that runs when a field changes is nested too; when I typed the second book's ISBN with a wrong check digit and left the field, without submitting the form:

2) second book, wrong check digit (typed, not submitted yet): ["ISBN is not a valid ISBN-13."]

When I gave both books the same ISBN and submitted, the message appeared not under a field but in the ValidationSummary; it is IValidatableObject's model-level result:

3) same ISBN twice: summary=["ISBN 9780156628709 appears more than once in this form."]
4) ISBN of Dune (exists in the API): fields=["A book with ISBN 9780441172719 already exists."]
5) after editing the field: fields=[]

Adding the API's Errors to the EditContext

The form knows whether an ISBN is valid, but not whether it is already in the database. Only Bookstore.Api can enforce that rule. In this post I added a POST /api/authors endpoint to the API:

public sealed record NewBookRequest(
    [property: Required, RegularExpression(@"^\d{13}$", ErrorMessage = "ISBN must be 13 digits.")] string Isbn,
    [property: Required, StringLength(200)] string Title,
    [property: Range(typeof(decimal), "1", "10000")] decimal Price,
    [property: Range(1450, 2100)] int PublishedYear,
    [property: Required] string CategorySlug);

public sealed record NewAuthorRequest(
    [property: Required, StringLength(200)] string Name,
    [property: MinLength(1)] IReadOnlyList<NewBookRequest> Books);
    // POST /api/authors { "name": "...", "books": [ { "isbn": "...", ... } ] }: a new author with their books.
    private static async Task<Results<Created<AuthorView>, ValidationProblem>> CreateAuthor(
        NewAuthorRequest request, BookstoreContext db, IInventoryService inventory, CancellationToken ct)
    {
        string[] isbns = [.. request.Books.Select(b => b.Isbn)];
        HashSet<string> taken = [.. await db.Books.Where(b => isbns.Contains(b.Isbn)).Select(b => b.Isbn).ToListAsync(ct)];
        Dictionary<string, int> categories = await db.Categories.ToDictionaryAsync(c => c.Slug, c => c.Id, ct);

        Dictionary<string, string[]> errors = [];
        for (int i = 0; i < request.Books.Count; i++)
        {
            NewBookRequest book = request.Books[i];
            if (taken.Contains(book.Isbn))
            {
                errors[$"Books[{i}].Isbn"] = [$"A book with ISBN {book.Isbn} already exists."];
            }
            if (!categories.ContainsKey(book.CategorySlug))
            {
                errors[$"Books[{i}].CategorySlug"] = [$"Unknown category '{book.CategorySlug}'."];
            }
        }
        if (errors.Count > 0)
        {
            return TypedResults.ValidationProblem(errors);
        }

The request records carry the API's own rules; it doesn't trust the panel's rules, because other clients can call the API too. Since AddValidation() is already on in the API project, the Minimal API validates the nested books with the same infrastructure before entering the handler. If the rules pass, the handler looks at the database and checks for ISBN conflicts and unknown categories. Both cases produce a response of the same shape; the error keys carry the path, as in Books[1].Isbn:

{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "errors": {
        "Books[1].Isbn": [
            "A book with ISBN 9780441172719 already exists."
        ],
        "Books[1].CategorySlug": [
            "Unknown category 'poetry'."
        ]
    },
    "traceId": "00-5ab0012ff0150edaf42216ff63cef52c-6759827229b1f667-00"
}

On the panel side, the typed client reads this response and returns it as a dictionary:

    // Null when the author was created; otherwise the API's validation errors by key, for example "Books[1].Isbn".
    public async Task<IDictionary<string, string[]>?> CreateAuthorAsync(NewAuthorRequest request, CancellationToken ct = default)
    {
        using HttpResponseMessage response = await http.PostAsJsonAsync("api/authors", request, ct);
        if (response.StatusCode == HttpStatusCode.BadRequest)
        {
            HttpValidationProblemDetails? problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>(ct);
            return problem?.Errors ?? new Dictionary<string, string[]>();
        }

        response.EnsureSuccessStatusCode();
        return null;
    }

The page's @code block distributes this dictionary to the form's fields:

@code {
    private readonly NewAuthorForm form = new();
    private EditContext editContext = null!;
    private ValidationMessageStore serverErrors = null!;
    private IReadOnlyList<CategoryView> categories = [];
    private bool saving;

    protected override async Task OnInitializedAsync()
    {
        editContext = new EditContext(form);
        serverErrors = new ValidationMessageStore(editContext);
        // A server error disappears as soon as the user edits that field.
        editContext.OnFieldChanged += (_, e) => serverErrors.Clear(e.FieldIdentifier);
        editContext.OnValidationRequested += (_, _) => serverErrors.Clear();
        categories = await Api.GetCategoriesAsync();
    }

    private async Task SaveAsync()
    {
        saving = true;
        NewAuthorRequest request = new(form.Name,
            [.. form.Books.Select(b => new NewBookRequest(
                Isbn13Attribute.Normalize(b.Isbn), b.Title, b.Price, b.PublishedYear, b.CategorySlug))]);
        IDictionary<string, string[]>? errors = await Api.CreateAuthorAsync(request);
        saving = false;
        if (errors is null)
        {
            // New books start with 0 copies; the inventory page lists them first.
            Navigation.NavigateTo("inventory");
            return;
        }

        foreach ((string key, string[] messages) in errors)
        {
            serverErrors.Add(ToField(key), messages);
        }
        editContext.NotifyValidationStateChanged();
    }

    // "Books[1].Isbn" -> the Isbn field of the second book; "Name" -> the form's Name; anything else -> the whole form.
    private FieldIdentifier ToField(string key)
    {
        Match match = Regex.Match(key, @"^Books\[(\d+)\]\.(\w+)$");
        if (match.Success && int.Parse(match.Groups[1].Value) is int index && index < form.Books.Count)
        {
            return new FieldIdentifier(form.Books[index], match.Groups[2].Value);
        }
        return key == nameof(NewAuthorForm.Name) ? new FieldIdentifier(form, key) : new FieldIdentifier(form, string.Empty);
    }
}

The key object is ValidationMessageStore: a store attached to the EditContext that holds its own messages. DataAnnotationsValidator uses the same thing internally, and our store sits next to it. FieldIdentifier identifies a field by an object and a property name; ToField turns the API's Books[1].Isbn key into the Isbn field of the second book object, so the message lands under the right ValidationMessage. Two event handlers decide when the messages are cleared: the server message of a field when the user changes that field, all of them when the form is validated again. When I entered Dune's ISBN for the second book and submitted, then corrected the field and submitted again:

6) saved, now on /inventory:
   Don Quixote Out of stock | 0 | 0 | 0
   Mrs Dalloway Out of stock | 0 | 0 | 0
   To the Lighthouse Out of stock | 0 | 0 | 0

The answer to the second question: the API caught the error, the message appeared under the second book's ISBN field and disappeared once the field was corrected. After saving, the page goes to the inventory screen; the new books are at the top of the list because their stock record starts at 0. The endpoint tests I ran against the API's local copy:

passed Bookstore.Api.Tests.AuthorTests.An_existing_isbn_is_reported_on_the_book_that_uses_it (182ms)
passed Bookstore.Api.Tests.AuthorTests.Field_rules_of_nested_books_are_validated (2ms)
passed Bookstore.Api.Tests.AuthorTests.Authors_are_listed_by_name_with_their_book_count (19ms)
passed Bookstore.Api.Tests.AuthorTests.A_new_author_is_created_with_books_that_start_with_no_stock (50ms)
Test run summary: Passed!
  total: 4
  failed: 0
  succeeded: 4

Because these tests add books, they run on their own database (AuthorTestsFactory, BookstoreDb_AuthorTests); otherwise the existing test that expects exactly 20 books in the catalog would break depending on the run order.

Validation in a Static SSR Form

I added a rule to the stock count form from 05 as well:

    <EditForm Model="StockCount" FormName="stock-count" OnValidSubmit="SaveStockAsync" Enhance>
        <DataAnnotationsValidator />
        <label>
            Counted quantity on hand
            <InputNumber @bind-Value="StockCount!.QuantityOnHand" class="form-control w-auto" />
        </label>
        <ValidationMessage For="() => StockCount!.QuantityOnHand" />
        <button type="submit" class="btn btn-primary">Save</button>
    </EditForm>
    public sealed class StockCountForm
    {
        // Same limits as Bookstore.Api's SetStockRequest; checked on the server after the POST.
        [Range(0, 10_000), Display(Name = "Quantity on hand")]
        public int QuantityOnHand { get; set; }
    }

This form is Static SSR; no circuit, no .NET in the browser. In Playwright I typed -5 and left the field, then submitted:

after typing -5: messages=[], requests=[]
after submit: messages=["The field Quantity on hand must be between 0 and 10000."], requests=["POST /books/9780141439549"]
input value after the round trip: -5
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/books/9780141439549
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/books/9780141439549

The answer to the third question: no. In .NET 10 a Static SSR form is validated on the server only after the POST. The message came when the page was rendered again, and the -5 that was entered stayed in the form. The two GETs in the log are the book reads of the page itself and of the render after the POST; there is no PUT /api/inventory, because OnValidSubmit did not run. In Static SSR, EditForm adds the antiforgery token itself, as we saw in 05. In an interactive form no POST leaves the browser; the values arrive over the circuit and the submit is a method call on the server.

Compared with Angular Reactive Forms

If you come from Angular, think about building the same form with Reactive Forms: a FormArray inside a FormGroup, one FormGroup per book. The difference is mostly in where the rules are written:

TopicAngular Reactive FormsBlazor EditForm
Form modelFormGroup, FormControl in TypeScriptA C# class and EditContext
RulesValidators.required, a custom ValidatorFnDataAnnotations attributes, IValidatableObject
Dynamic listFormArray.push / removeAtAdding to and removing from a List<T>
Server errorcontrol.setErrorsValidationMessageStore.Add
Field statetouched, dirty, validIsModified, GetValidationMessages
Same rule as the APIWritten twice, in C# and TypeScriptThe same attribute can be used on both sides

The last row is Blazor's most concrete advantage, but it is not free: in our solution Isbn13Attribute lives in the panel and the API only checks for 13 digits. To really keep the rule in one place you need a shared library that both reference; such a library will become necessary in post 09, where we talk about the panel's data access. When rules get complex (conditional rules, rules that look at the database, reusing rule sets), FluentValidation is a frequent choice; there are community packages that connect it to Blazor through the EditContext. In this series we stay with DataAnnotations.

What .NET 11 Brings for Forms

This section is about .NET 11; the series' code stays on .NET 10 LTS. In .NET 11 the answer to the third question is different: Static SSR forms are also validated in the browser by default when they contain a DataAnnotationsValidator. I tried the same stock count form in a small project created with the .NET 11 SDK:

after typing -5: message=The field Quantity on hand must be between 0 and 10000., requests=[]
after submit: message=The field Quantity on hand must be between 0 and 10000., requests=[]
after 30 and submit: message=(hidden), requests=["POST /stock-count-validated"]

The message appeared as soon as I left the field, and for the invalid form no POST was sent at all. The server writes the rules into the HTML as metadata and Blazor's JavaScript applies them in the browser; here it is with the quotes decoded for readability:

<input step="any" type="number" id="Form_QuantityOnHand" name="Form.QuantityOnHand" class="valid" value="0" />
<div class="validation-message" data-valmsg-for="Form.QuantityOnHand" data-valmsg-replace="true" hidden></div>
<button type="submit">Save</button><blazor-client-validation-data data-rules="{"fields":[{"name":"Form.QuantityOnHand","rules":[{"name":"range","message":"The field Quantity on hand must be between 0 and 10000.","params":{"min":"0","max":"10000"}}]}]}"></blazor-client-validation-data></form>

The rules still come from the C# model and are validated again on the server after the POST; the check in the browser is only early feedback for the user. .NET 11 also brings async validation: rules that look at a database or an API are awaited all the way through the EditForm submit. The "does this ISBN exist" check in the Dune example could be such a rule. We will look at both in more detail in post 13.

In Production

The rules in the form are there for fast feedback to the user, not for security. In InteractiveServer the rules run on the server, so they look impossible to skip, but other clients can call the API too. The authoritative owner of the rule is the API; the panel repeats it to show it early.

In an InteractiveServer form every field change is a message to the server over the circuit, and every validation is work on the server. That is negligible for a small form; but if you validate on every keystroke with @bind:event="oninput" and an expensive rule is among them, it becomes a cost multiplied by the number of users. The default onchange is enough for most forms.

The user may press Save twice. The page's saving flag disables the button while the request is running; still, the real protection is the ISBN's unique index on the API side. If two requests arrive at the same moment, both may pass the pre-check, and the second one runs into the database's unique index; returning that to the user as a proper validation message needs separate handling.

Common Pitfalls

Forgetting AddValidation(). The book fields are silently not validated; in the test above we saw one message instead of six. According to the documentation, writing the model inside a .razor file has the same result, because the source generator can't see that class.

Writing For="() => form.Books[i].Isbn" in the loop. The lambda captures i; when ValidationMessage evaluates the expression, the loop has finished and i equals the list's length. When I tried it, the page returned 500 as it opened:

System.ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
   at lambda_method115(Closure)
   at Microsoft.AspNetCore.Components.Forms.FieldIdentifier.ParseAccessor[T](Expression`1 accessor, Object& model, String& fieldName)

Assign it to a local variable inside the loop, as in NewBookForm book = form.Books[i];.

Giving EditForm both a Model and an EditContext. Blazor does not accept that; if you need to add server errors, use only EditContext.

Never clearing the server messages. A message added to a ValidationMessageStore doesn't go away by itself; even after the user corrects the field, "already exists" stays on the screen and OnValidSubmit never runs again. Clear them in OnFieldChanged and OnValidationRequested.

Using OnSubmit in a Static SSR form and expecting validation. OnSubmit leaves validation to you; even if you add the rule attributes, an invalid value goes to the API unless you call editContext.Validate(). That is why the form from 05 moved to OnValidSubmit.

A Small Experiment

★☆☆ Remove [Display(Name = "Year")] from NewBookForm.PublishedYear and submit the empty form again. What happened to the message? Try the same with Price's ErrorMessage.

★★☆ Add a publication date field for the book: DateOnly? PublishedOn with InputDate. Write a rule that rejects a date in the future; is it better written as an attribute or inside IValidatableObject?

★★★ Move Isbn13Attribute to a place both the panel and Bookstore.Api can use, and replace the API's RegularExpression rule with it. Which project did you have to add a reference to? Write a new API test for an ISBN with a wrong check digit.

We built the form, but we keep writing similar markup on the new author page, the stock table and the book list. In the next post we move on to component design: templated components with RenderFragment, a generic table, CascadingValue, @key, QuickGrid, CSS isolation and testing all of it with bUnit.