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

Component Design: RenderFragment, Templated and Generic Components, CascadingValue, CSS Isolation and bUnit

Mert Özen Nov 15, 2026 16 min 5 views
Component Design: RenderFragment, Templated and Generic Components, CascadingValue, CSS Isolation and bUnit

Reusable Blazor components are built with RenderFragment and @typeparam; @key ties row state to the right item, ShouldRender cuts needless renders, and cascading values don't cross render mode boundaries. In the Bookstore panel we show a generic DataTable, an input that moves to the wrong row without @key, renders dropping from 42 to 4, a cascading value that is null in WebAssembly, QuickGrid, a Virtualize measurement, ::deep and bUnit tests, 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), Microsoft.AspNetCore.Components.QuickGrid 10.0.15 and bUnit 2.15.2 (xUnit v3 4.0.3); browser behaviour was observed with Playwright 1.65's headless Chromium.

Predict First

In the admin panel of Bookstore, the online bookstore used as the shared example across this site's series, each row of the stock table is a BookRow component, and each row has its own "how many copies to add" input. The table is sorted starting with the book that has the least stock. When you type 25 into Don Quixote's row and press Restock, the book moves down the list. Whose row does the input with 25 stay in: Don Quixote's, or the book that moves to the top in its place? Second question: when you move the threshold from 30 to 31, a change that alters no badge, how many of the 20 rows are rendered again? Third: you provide the currency from the layout with a <CascadingValue>; does the same value reach post 04's WebAssembly CartPreview?

A Generic, Templated Table

Similar tables are multiplying in the panel: the stock list, the book list, the authors. There are two tools for writing the table's skeleton once and leaving its content to the caller. A RenderFragment is a piece of markup; a RenderFragment<T> is a piece that takes a value and produces markup. @typeparam makes the component generic:

@typeparam TItem

<table class="table table-sm align-middle data-table">
    <thead>
        <tr>@HeaderTemplate</tr>
    </thead>
    <tbody>
        @if (Items.Count == 0)
        {
            <tr><td colspan="100" class="text-muted">@(EmptyTemplate ?? DefaultEmpty)</td></tr>
        }
        @foreach (TItem item in Items)
        {
            // The key keeps each row's DOM and component state with its item when the list is reordered.
            <tr @key="ItemKey(item)">@RowTemplate(item)</tr>
        }
    </tbody>
</table>

@code {
    private static readonly RenderFragment DefaultEmpty = builder => builder.AddContent(0, "Nothing to show.");

    [Parameter, EditorRequired]
    public IReadOnlyList<TItem> Items { get; set; } = [];

    [Parameter, EditorRequired]
    public Func<TItem, object> ItemKey { get; set; } = null!;

    [Parameter, EditorRequired]
    public RenderFragment HeaderTemplate { get; set; } = null!;

    [Parameter, EditorRequired]
    public RenderFragment<TItem> RowTemplate { get; set; } = null!;

    [Parameter]
    public RenderFragment? EmptyTemplate { get; set; }
}

In Angular terms, HeaderTemplate is a named ng-content slot and RowTemplate is an ng-template that takes a context. The stock table now looks like this:

@inject ILogger<BookList> Logger

<DataTable Items="Items" ItemKey="item => item.Isbn">
    <HeaderTemplate>
        <th>Title</th><th class="text-end">On hand</th><th class="text-end">Reserved</th><th class="text-end">Available</th><th></th>
    </HeaderTemplate>
    <RowTemplate Context="item">
        <BookRow Item="item" LowStockThreshold="LowStockThreshold" OnRestock="OnRestock" />
    </RowTemplate>
    <EmptyTemplate>No books match the filter.</EmptyTemplate>
</DataTable>
<p>@Items.Count(i => i.Available <= LowStockThreshold) of @Items.Count books are at or below the threshold.</p>

Child content is written as an element named after the parameter (<RowTemplate>). Context="item" is the name of the variable inside the template; without it the name is context, and since that clashes in two nested templates, giving it a name is a good habit. We didn't write TItem; the compiler infers StockView from Items="Items". BookRow no longer draws the <tr> itself either; it produces only the cells, and the row element is in DataTable.

@key and ShouldRender: Which Row Renders When

When Blazor renders a list again, it compares the new render tree with the old one. Without a hint it matches items by their position: the first row of the old list becomes the first row of the new one. If the rows are plain HTML, that's fine. But if each row has a component and that component holds state, the state stays tied to the position. With the code as it was at the end of 07, I tried the first question in Playwright:

before restock (first 3 rows):
   Don Quixote            available  0  amount 25
   War and Peace          available  3  amount 10
   1984                   available 25  amount 10
after restocking Don Quixote by 25 (first 3 rows, and Don Quixote):
   War and Peace          available  3  amount 25
   1984                   available 25  amount 10
   Anna Karenina          available 25  amount 10
   Don Quixote            available 25  amount 10  (row 6)

The 25 moved to War and Peace's row. Don Quixote went down to sixth place, but the BookRow instance showing it is the one that was already there; its amount is 10. With the next click the user adds 25 copies of War and Peace without ever having typed 25 for it. The <tr @key="ItemKey(item)"> in DataTable fixes this: Blazor matches the rows by ISBN, and the row's DOM and the component inside it move together:

before restock (first 3 rows):
   Don Quixote            available  0  amount 25
   War and Peace          available  3  amount 10
   1984                   available 25  amount 10
after restocking Don Quixote by 25 (first 3 rows, and Don Quixote):
   War and Peace          available  3  amount 10
   1984                   available 25  amount 10
   Anna Karenina          available 25  amount 10
   Don Quixote            available 25  amount 25  (row 6)

The answer to the first question: without @key in the wrong book's row, with @key in Don Quixote's. Use a stable, unique value such as a database ID or the ISBN as the key; the list index is useless as a key, because matching by index is the problem in the first place.

The second topic is the number of renders. When a parent renders, its child component's parameters are set again. Blazor can only tell that a parameter did not change for known simple types (strings, numbers, DateTime and the like); when a record or class such as StockView is passed as a parameter, it assumes "may have changed" every time and renders the child. To measure this I made BookRow write a log line on every render in an experimental copy and counted three steps:

threshold 5 -> 30: 20 BookRow renders
threshold 30 -> 31: 20 BookRow renders
restock Emma by 10: 42 BookRow renders

Even though the threshold changed no badge, 20 rows were rendered; restocking a single book cost 42 renders. With a ShouldRender override I told BookRow what its markup depends on:

@code {
    private int amount = 10;
    private bool saving;
    private RowState? rendered;

    [Parameter, EditorRequired]
    public StockView Item { get; set; } = null!;

    [Parameter]
    public int LowStockThreshold { get; set; }

    [Parameter]
    public EventCallback<RestockRequest> OnRestock { get; set; }

    // Everything the markup depends on. StockView is a record, so equal data compares equal.
    private RowState Current => new(Item, Item.Available <= LowStockThreshold, saving);

    // Skip the render when nothing visible changed, e.g. a threshold change that doesn't move this row's badge.
    protected override bool ShouldRender() => Current != rendered;

    protected override void OnAfterRender(bool firstRender) => rendered = Current;

    private async Task RestockClicked()
    {
        saving = true;
        await OnRestock.InvokeAsync(new RestockRequest(Item.Isbn, amount));
        saving = false;
    }

    private sealed record RowState(StockView Item, bool IsLow, bool Saving);
}
threshold 5 -> 30: 18 BookRow renders
threshold 30 -> 31: 0 BookRow renders
restock Emma by 10: 4 BookRow renders

The answer to the second question: 20 before, 0 now. Going from 5 to 30 rendered the 18 rows whose badge actually changed; restocking dropped to 4 renders. ShouldRender isn't called for the first render; a component is always drawn at least once. This technique is for expensive rows and frequently updated lists; add it to every component and one day you will forget a piece of state and the screen won't update. The only thing not in RowState here is amount; the input's value is already in the DOM, so no render is needed for it.

@ref: References to Components and Elements

Sometimes a parent needs to reach a child or an HTML element directly. In the author form from 07, I wanted to focus the new book's ISBN field after "Add another book":

            <label>ISBN <InputText @ref="lastIsbnInput" @bind-Value="book.Isbn" class="form-control" /></label>
    // Inside the loop every row assigns this field, so after a render it holds the last book's input.
    private InputText? lastIsbnInput;
    private bool focusLastBook;

    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 void AddBook()
    {
        form.Books.Add(new());
        focusLastBook = true;
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (focusLastBook && lastIsbnInput?.Element is ElementReference input)
        {
            focusLastBook = false;
            await input.FocusAsync();
        }
    }
after "Add another book": focus on Book 2, ISBN input
after "Add another book": focus on Book 3, ISBN input

@ref is assigned during rendering; since every row in the loop writes to the same field, when the render is done the field holds the last book's InputText. The input components' Element property is the ElementReference of the underlying <input>; FocusAsync uses JS interop under the hood, so it is called in OnAfterRenderAsync, after the element is in the DOM. Don't use component references to call methods instead of passing parameters; data should flow down through parameters and events up through EventCallback.

CascadingValue and the Render Mode Boundary

Prices were written with ToString("N2") TRY in four different places in the panel. I moved that into a small Money component; instead of passing the currency and format to every instance, they come as a cascading value:

@Amount.ToString(Options.AmountFormat) @Options.Currency

@code {
    [Parameter, EditorRequired]
    public decimal Amount { get; set; }

    [CascadingParameter]
    public PanelOptions Options { get; set; } = null!;
}

My first attempt was to provide the value from the layout:

<article class="content px-4">
    <CascadingValue Value="options" IsFixed="true">
        @Body
    </CascadingValue>
</article>

@code {
    private readonly PanelOptions options = new(Currency: "TRY", AmountFormat: "N2");
}

IsFixed="true" says the value will never change; Blazor then doesn't track recipients for changes. If the value can change, every recipient subscribes and all of them render again when it changes; that is why a frequently changing cascading value at the top of a deep tree is expensive. This setup worked on the Static SSR pages. In the WebAssembly CartPreview on the quote page, however:

prerendered HTML: <option value="9780451524935" selected>1984 (279.00 TRY)</option>
after WebAssembly started: 1984 ()
renderer: WebAssembly
console error: crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100] |       Unhandled exception rendering component: Arg_NullReferenceException
console error: crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100] |       Unhandled exception rendering component: Arg_NullReferenceException

The answer to the third question: yes during prerendering, then no. Prerendering on the server runs inside the same render tree, so the value arrived and the HTML contained "279.00 TRY". When the WebAssembly runtime started and re-created the component in the browser, Options was null. The documentation says this plainly: cascading parameters don't cross render mode boundaries and are not serialized to JSON. The fix is a root-level cascading value; it comes through DI, so it exists in every render mode, but each side makes its own registration:

// A root-level cascading value reaches every component, whatever its render mode (the WebAssembly side registers its own).
builder.Services.AddCascadingValue(_ => new PanelOptions(Currency: "TRY", AmountFormat: "N2"));
using Bookstore.Admin.Client.Shared;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddCascadingValue(_ => new PanelOptions(Currency: "TRY", AmountFormat: "N2"));

await builder.Build().RunAsync();
prerendered HTML: <option value="9780451524935" selected>1984 (279.00 TRY)</option>
after WebAssembly started: 1984 (279.00 TRY)
renderer: WebAssembly

A value provided with AddCascadingValue like this is fixed. If it must change at run time, you register a CascadingValueSource<T> and call NotifyChangedAsync; the documentation also notes that Static SSR recipients can't receive this notification.

Long Lists: Virtualize and QuickGrid

The panel doesn't have a really long list yet; to measure, I put 10,000 synthetic stock movements on a diagnostics page:

    <table class="table table-sm mb-0">
        <tbody>
            @if (virtualize)
            {
                <Virtualize Items="movements" ItemSize="33" OverscanCount="5" SpacerElement="tr" Context="movement">
                    <tr @key="movement.Id" style="height: 33px">
                        <td>@movement.Id</td><td>@movement.Isbn</td><td class="text-end">@movement.Change</td><td>@movement.AtUtc.ToString("u")</td>
                    </tr>
                </Virtualize>
            }

Virtualize renders only the visible rows plus OverscanCount more; the rest of the height is held by two spacer elements. Because I use it inside a table, the spacer must be a tr (SpacerElement="tr"); ItemSize states the row height in advance so the first render is calculated correctly. The measurement:

Virtualize on: 28 <tr> in the DOM (including 2 spacers)
Virtualize off: 10000 <tr>, 304 ms until all rows were in the DOM, 3169 KB over the circuit
Virtualize on again: 28 <tr>, 99 ms
scrolled to the bottom: 28 <tr>, ids 9975..10000, 10 KB over the circuit

With Virtualize off, the InteractiveServer circuit sent a 3.1 MB render batch to the browser and 10,000 rows were created in the DOM; with it on, 28 rows, and when scrolling to the bottom only 10 KB for the rows that became visible. This was measured with a browser on the same machine; on a real network a 3 MB difference means seconds. If the data is on the server, you can also use ItemsProvider instead of Items and request only the visible range.

For tables that need sorting and paging, look at Microsoft's QuickGrid package instead of growing your own DataTable. The authors page:

@page "/authors"
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Components.QuickGrid
@inject BookstoreApiClient Api

<PageTitle>Authors</PageTitle>

<h1>Authors</h1>
<p><a href="authors/new" class="btn btn-sm btn-primary">New author</a></p>

@if (Authors is null)
{
    <p>Loading...</p>
}
else
{
    <QuickGrid Items="Authors.AsQueryable()" Pagination="pagination" class="table table-sm">
        <PropertyColumn Property="a => a.Name" Sortable="true" IsDefaultSortColumn="true" />
        <PropertyColumn Property="a => a.BookCount" Title="Books" Sortable="true" Align="Align.End" />
    </QuickGrid>
    <Paginator State="pagination" />
}

@code {
    private readonly PaginationState pagination = new() { ItemsPerPage = 5 };

    // Loaded once during prerendering (see post 06); sorting and paging then happen in memory on the circuit.
    [PersistentState]
    public List<AuthorView>? Authors { get; set; }

    protected override async Task OnInitializedAsync() => Authors ??= [.. await Api.GetAuthorsAsync()];
}
page 1: Aldous Huxley 1, Alexandre Dumas 1, Charles Dickens 1, Charlotte Brontë 1, Emily Brontë 1
paginator: 18 items Page 1 of 4
sorted by Books (2 clicks): Jane Austen 2, Leo Tolstoy 2, Aldous Huxley 1, Alexandre Dumas 1, Charles Dickens 1
next page: Charlotte Brontë 1, Emily Brontë 1, F. Scott Fitzgerald 1, Frank Herbert 1, Fyodor Dostoevsky 1
paginator: 18 items Page 2 of 4

PropertyColumn derives the title and sorting from the expression, and Paginator with PaginationState handles paging. The data comes once from the GET /api/authors endpoint we added in 07 and is carried over from prerendering with [PersistentState] as in 06; sorting and paging happen in memory on the circuit. With thousands of rows you would pass an ItemsProvider instead of Items and leave sorting and paging to the API. In .NET 10 QuickGrid's sort and page buttons work through @onclick, so it needs an interactive render mode; .NET 11 brings URL-based paging and sorting for Static SSR, covered in post 13.

CSS Isolation and ::deep

The DataTable.razor.css file holds styles that belong only to DataTable. At build time Blazor adds an attribute starting with b- to the elements this component renders and ties the CSS selectors to that attribute; the styles of all components are collected in the Bookstore.Admin.styles.css bundle. The first rule I wrote was .data-table thead th; in the bundle it turned into this:

/* /Components/Shared/DataTable.razor.rz.scp.css */
.data-table thead th[b-stp7rrk8id] {
    font-size: 0.8rem;
    text-transform: uppercase;
    color: #6c757d;
}
thead th: scope=(none) text-transform=none font-size=16px
tbody td: scope=(none) padding-top=5.6px

The header style was not applied. The reason is that the attribute is added to the rightmost part of the selector: th[b-stp7rrk8id]. But the <th> elements don't come from DataTable; they come from the template of BookList, which uses it, and since that component has no scoped CSS they carry no attribute at all. Only the <table> that DataTable writes itself carries the attribute. ::deep puts the attribute exactly there:

/* Header and body cells come from the parent's templates and don't get this component's scope attribute,
   so both rules go through ::deep. Only <table> itself, rendered here, carries the attribute. */
.data-table ::deep thead th {
    font-size: 0.8rem;
    text-transform: uppercase;
    color: #6c757d;
}

.data-table ::deep tbody td {
    padding-top: 0.35rem;
    padding-bottom: 0.35rem;
}
.data-table[b-stp7rrk8id]  thead th {
    font-size: 0.8rem;
    text-transform: uppercase;
    color: #6c757d;
}

.data-table[b-stp7rrk8id]  tbody td {
    padding-top: 0.35rem;
    padding-bottom: 0.35rem;
}
thead th: scope=(none) text-transform=uppercase font-size=12.8px
tbody td: scope=(none) padding-top=5.6px

The bundle output is trimmed (comment lines removed). The rule is simple: in a templated component, everything that comes from a template is reached with ::deep. ::deep only selects elements below an element that carries the attribute; had the component rendered the template content without wrapping it in any element, these rules could not catch it. In DataTable that wrapper is the <table>.

Component Testing with bUnit

Bookstore.Api had tests; the panel didn't. Since the Frontend Testing series focuses on Angular, Blazor testing is here: a new tests/Bookstore.Admin.Tests project with xUnit v3 and bUnit. bUnit runs the component in memory with a renderer, without a browser; you can query the markup and trigger events:

    [Fact]
    public async Task Restock_raises_the_callback_and_shows_saving_until_it_completes()
    {
        TaskCompletionSource done = new();
        RestockRequest? raised = null;
        IRenderedComponent<BookRow> row = Render<BookRow>(p => p
            .Add(r => r.Item, Emma)
            .Add(r => r.LowStockThreshold, 5)
            .Add(r => r.OnRestock, (RestockRequest request) => { raised = request; return done.Task; }));

        row.Find("input").Change("15");
        row.Find("button").Click();

        Assert.Equal(new RestockRequest("9780141439587", 15), raised);
        Assert.Equal("Saving...", row.Find("button").TextContent.Trim());
        Assert.True(row.Find("button").HasAttribute("disabled"));

        done.SetResult();
        await row.WaitForAssertionAsync(() => Assert.Equal("Restock", row.Find("button").TextContent.Trim()));
    }

    [Fact]
    public void A_threshold_change_that_does_not_move_the_badge_does_not_render_the_row()
    {
        IRenderedComponent<BookRow> row = Render<BookRow>(p => p.Add(r => r.Item, Emma).Add(r => r.LowStockThreshold, 5));
        int before = row.RenderCount;

        row.Render(p => p.Add(r => r.LowStockThreshold, 6));
        Assert.Equal(before, row.RenderCount);

        row.Render(p => p.Add(r => r.LowStockThreshold, 30));
        Assert.Equal(before + 1, row.RenderCount);
        Assert.Contains("Low: 25 left", row.Markup);
    }

Render<T> draws the component with parameters, Find finds an element with a CSS selector, and Change and Click trigger events. I passed a lambda to the EventCallback parameter to capture the call, and used a TaskCompletionSource to keep the parent's work pending and see the "Saving..." state. RenderCount confirms that ShouldRender really skips the render. In the page test I replaced Bookstore.Api with a fake HttpMessageHandler:

    public NewAuthorPageTests()
    {
        // A fake Bookstore.Api: categories for the select list, a 400 for an ISBN that already exists.
        StubHandler handler = new(request =>
        {
            requests.Add($"{request.Method} {request.RequestUri!.AbsolutePath}");
            return request.RequestUri.AbsolutePath switch
            {
                "/api/categories" => Json(HttpStatusCode.OK, new[] { new CategoryView("Classics", "classics") }),
                "/api/authors" => Json(HttpStatusCode.BadRequest, new
                {
                    title = "One or more validation errors occurred.",
                    status = 400,
                    errors = new Dictionary<string, string[]> { ["Books[0].Isbn"] = ["A book with ISBN 9780441172719 already exists."] }
                }),
                _ => new HttpResponseMessage(HttpStatusCode.NotFound)
            };
        });
        Services.AddSingleton(new BookstoreApiClient(new HttpClient(handler) { BaseAddress = new Uri("http://localhost:5080/") }));
        Services.AddAdminValidation();
    }

    [Fact]
    public void An_empty_form_is_not_sent_to_the_api()
    {
        IRenderedComponent<NewAuthorPage> page = Render<NewAuthorPage>();

        page.Find("form").Submit();

        Assert.Equal(6, page.FindAll("div.validation-message").Count);
        Assert.DoesNotContain("POST /api/authors", requests);
    }

On the first run of this test the empty form produced 1 message instead of 6:

failed Bookstore.Admin.Tests.NewAuthorPageTests.An_empty_form_is_not_sent_to_the_api (10ms)
  Assert.Equal() Failure: Values differ
  Expected: 6
  Actual:   1

The reason is the source generator from 07. The AddValidation() call is bound to the validation metadata generated in the assembly where it is called. When I wrote Services.AddValidation() in the test project, the generator ran in the test project, which has no [ValidatableType], so there were no nested rules. I moved the call into the panel as an extension method; both Program.cs and the tests use it:

namespace Bookstore.Admin.Validation;

public static class ValidationServiceExtensions
{
    // The validation source generator emits metadata for the assembly that calls AddValidation().
    // Calling it here, inside Bookstore.Admin, lets Program.cs and the bUnit tests share the same metadata.
    public static IServiceCollection AddAdminValidation(this IServiceCollection services) => services.AddValidation();
}
passed InventoryComponentTests.A_threshold_change_that_does_not_move_the_badge_does_not_render_the_row
passed InventoryComponentTests.Badge_depends_on_available_stock_and_threshold(available: 0, threshold: 5, expected: "<span class=\"badge bg-danger\">Out of stock</span>")
passed InventoryComponentTests.Badge_depends_on_available_stock_and_threshold(available: 3, threshold: 5, expected: "<span class=\"badge bg-warning text-dark\">Low: 3 le"···)
passed InventoryComponentTests.Badge_depends_on_available_stock_and_threshold(available: 8, threshold: 5, expected: "")
passed InventoryComponentTests.Restock_raises_the_callback_and_shows_saving_until_it_completes
passed InventoryComponentTests.The_list_shows_its_empty_template_when_nothing_matches
passed NewAuthorPageTests.An_api_error_appears_under_the_field_it_belongs_to
passed NewAuthorPageTests.An_empty_form_is_not_sent_to_the_api
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "", valid: True)
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "978-0-15-662870-9", valid: True)
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "978015690739", valid: False)
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "9780156907391", valid: False)
passed SharedComponentTests.Isbn13_checks_length_and_check_digit(isbn: "9780441172719", valid: True)
passed SharedComponentTests.Money_formats_with_the_cascaded_panel_options
Test run summary: Passed!
  total: 14
  failed: 0
  succeeded: 14

The output is trimmed (namespaces and durations removed). You can run the tests with dotnet test tests/Bookstore.Admin.Tests; no database or running API is needed.

In Production

In InteractiveServer every render is a diff calculation and a message over the circuit. The 3.1 MB in the Virtualize measurement is the extreme case; the everyday version is 20 rows rendering again on every keystroke in an input. When you suspect a bottleneck, measure first: a log line or the browser's WebSocket frames show how many renders and how many bytes an operation produced. Adding ShouldRender without measuring means code that is hard to read and easy to get wrong.

Root-level cascading values come from DI, so they live for the whole application. When you register a user-specific value (the user's language preference, for example) with AddCascadingValue, watch the lifetime; what DI lifetimes mean on Server and on WebAssembly is the topic of the next post.

The scoped CSS bundle is loaded by <link href="@Assets["Bookstore.Admin.styles.css"]"> in App.razor, and the file name is fingerprinted (Bookstore.Admin.s7im66pvkc.styles.css). When you set up a reverse proxy or a CDN, know that this file can be cached for a long time and that its name changes whenever its content changes.

Common Pitfalls

Using stateful components in a loop without @key. Sorting, filtering or inserting at the top moves the state to the wrong row, like the 25 above.

Giving @key a value that isn't unique. When I made the key item.Available as an experiment, the page opened, and on the first re-render (changing the threshold) the circuit failed with this error:

System.InvalidOperationException: More than one sibling of element 'tr' has the same key value, '25'. Key values must be unique.

The error doesn't show up right away because the first render has no old list to compare with; values that can repeat, such as a title, an author's name or a count, should not be keys.

Assuming a cascading value reaches an interactive island. While the layout and Routes are Static SSR, a value provided there works during prerendering and becomes null in interactive rendering. For values that must reach interactive components, use a root-level cascading value, with a separate registration on the WebAssembly side.

Applying scoped CSS to elements that come from a template without ::deep. There is no error; the style just isn't applied.

Calling AddValidation() again in the test project. The app's metadata doesn't come to the test project; the nested rules are silently off, and the test tests its own incomplete setup instead of the app's behaviour.

A Small Experiment

★☆☆ Replace the hand-written table in BookListPage with DataTable<BookSummary>. Think about whether @key does anything in Static SSR: if the page is rendered from scratch on every request, is there an old tree to compare with? What about enhanced navigation?

★★☆ On the inventory page, instead of passing the threshold through BookList and BookRow as a parameter, provide it with <CascadingValue Value="Filter.Threshold"> and read it in LowStockBadge as a [CascadingParameter]. This time you can't use IsFixed; measure the render counts as above and compare with ShouldRender.

★★★ Give the QuickGrid on the authors page an ItemsProvider: add page, pageSize and sort parameters to GET /api/authors and let the API do the sorting and paging. In bUnit, write a test with a fake handler for "moving to the second page sends page=2 to the API".

Our components are tidier now, but they all get their data from Bookstore.Api through a typed HttpClient, and in 06 we noted that a Singleton such as StaffNoteStore is shared by all users. In the next post we look at what DI lifetimes mean on Server and on WebAssembly, the circuit scope, how to use DbContext in components, and a data abstraction that works on both sides in Auto mode.