blazor
Blazor Guide
Applies to: Blazor (.NET 8+), C# 12+, WebAssembly, Server, Blazor United, Interactive Web Apps
Core Principles
- Component-Based: Build encapsulated
.razorcomponents that own their state and rendering - Hosting Flexibility: Choose WebAssembly, Server, or Blazor United (hybrid) per-component
- C# Everywhere: Share models, validation, and logic between client and server
- Type Safety: Leverage nullable reference types and strong parameter typing
- Lifecycle Awareness: Understand component lifecycle to avoid leaks and race conditions
Hosting Models
| Model | Runs In | Best For |
|---|---|---|
| WebAssembly | Browser (WASM) | Offline-capable apps, PWAs, reduced server load |
| Server | Server (SignalR) | Low-latency, SEO, thin clients |
| United (.NET 8) | Hybrid SSR + interactive | Per-component render mode selection |
Guardrails
Component Rules
- Keep components under 200 lines (split with child components)
- Use
[Parameter, EditorRequired]for required parameters - Never mutate
[Parameter]properties directly -- use local state instead - Use
EventCallback<T>for parent-child communication (notAction<T>) - Implement
IDisposablewhen subscribing to events, timers, or state changes - Use
@keydirective on list-rendered items for DOM diffing performance - Wrap component trees in
<ErrorBoundary>for graceful error handling - Use
RenderFragmentandRenderFragment<T>for templated components
Lifecycle Rules
- Do NOT call JS interop in
OnInitialized/OnInitializedAsync-- useOnAfterRender - Use
OnParametersSetfor reacting to parameter changes (not property setters) - Call
StateHasChanged()only when necessary (avoid triggering excessive re-renders) - Use
ShouldRender()override to prevent unnecessary renders on hot paths - Always use
CancellationTokenwith async lifecycle methods to handle disposal
Data Binding
- Use
@bind-Valuefor two-way binding with input components - Use
@bind:eventto control when binding updates (e.g.,oninputvsonchange) - Use
EditFormwithDataAnnotationsValidatorfor form validation - Never use
@bindand@onchangeon the same element (they conflict)
Security
- Use
[Authorize]attribute on pages requiring authentication - Use
<AuthorizeView>for conditional UI based on auth state - Validate all inputs server-side (client validation is for UX, not security)
- Never trust Blazor WASM for authorization decisions -- enforce on API
- Use antiforgery tokens for form submissions in SSR mode
Performance
- Use
<Virtualize>for large lists instead of@foreach - Use
@rendermodeappropriately (static SSR by default, interactive only when needed) - Use
[StreamRendering]for pages with async data loading - Minimize JS interop calls (batch operations when possible)
- Use
IMemoryCacheorBlazored.LocalStoragefor client-side caching - Lazy-load assemblies for large WASM apps with
LazyAssemblyLoader
Project Structure
MyApp/
├── MyApp.Client/ # Blazor WebAssembly / interactive
│ ├── Pages/ # Routable components (@page)
│ │ ├── Home.razor
│ │ └── Users/
│ │ ├── UserList.razor
│ │ ├── UserDetail.razor
│ │ └── UserForm.razor
│ ├── Components/ # Reusable components
│ │ ├── Layout/
│ │ │ ├── MainLayout.razor
│ │ │ └── NavMenu.razor
│ │ ├── Common/
│ │ │ ├── LoadingSpinner.razor
│ │ │ ├── ErrorBoundary.razor
│ │ │ └── Modal.razor
│ │ └── Users/
│ │ └── UserCard.razor
│ ├── Services/ # Client-side services
│ │ ├── IUserService.cs
│ │ └── UserService.cs
│ ├── State/ # State management
│ │ └── AppState.cs
│ ├── wwwroot/ # Static assets
│ ├── _Imports.razor # Global using directives
│ ├── App.razor # Root component
│ └── Program.cs # DI and startup
├── MyApp.Server/ # API / server host
│ ├── Controllers/
│ └── Program.cs
├── MyApp.Shared/ # Shared models and DTOs
│ ├── Models/
│ └── DTOs/
├── tests/
│ ├── MyApp.Client.Tests/ # bUnit component tests
│ └── MyApp.Server.Tests/ # API integration tests
└── MyApp.sln
Layer Responsibilities
| Layer | Purpose | Depends On |
|---|---|---|
| Client | UI components, pages, client services | Shared |
| Server | API endpoints, server logic, auth | Shared |
| Shared | Models, DTOs, validation attributes | Nothing |
| Tests | bUnit component tests, API tests | Client, Server |
Component Patterns
Page Component with Data Loading
@page "/users"
@attribute [Authorize]
@inject IUserService UserService
<PageTitle>Users</PageTitle>
@if (loading)
{
<LoadingSpinner />
}
else if (error is not null)
{
<div class="alert alert-danger">
<p>@error</p>
<button class="btn btn-outline-danger" @onclick="LoadUsers">Retry</button>
</div>
}
else if (users is null || users.Count == 0)
{
<div class="alert alert-info">No users found.</div>
}
else
{
@foreach (var user in users)
{
<UserCard @key="user.Id" User="user" OnEdit="() => Edit(user.Id)" />
}
}
@code {
private List<UserResponse>? users;
private bool loading = true;
private string? error;
protected override async Task OnInitializedAsync()
{
await LoadUsers();
}
private async Task LoadUsers()
{
loading = true;
error = null;
try
{
users = await UserService.GetUsersAsync();
}
catch (Exception ex)
{
error = ex.Message;
}
finally
{
loading = false;
}
}
private void Edit(long id) => Navigation.NavigateTo($"/users/{id}/edit");
}
Reusable Component with Parameters
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">@User.FirstName @User.LastName</h5>
<small class="text-muted">@User.Email</small>
<span class="badge @(User.Active ? "bg-success" : "bg-secondary")">
@(User.Active ? "Active" : "Inactive")
</span>
</div>
<div class="card-footer">
<button class="btn btn-sm btn-outline-primary" @onclick="OnEdit">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="OnDelete">Delete</button>
</div>
</div>
@code {
[Parameter, EditorRequired]
public UserResponse User { get; set; } = default!;
[Parameter]
public EventCallback OnEdit { get; set; }
[Parameter]
public EventCallback OnDelete { get; set; }
}
Templated Component (RenderFragment)
@code {
[Parameter] public string Title { get; set; } = "Modal";
[Parameter] public RenderFragment? BodyContent { get; set; }
[Parameter] public RenderFragment? FooterContent { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
}
Use RenderFragment for content slots, RenderFragment<T> for typed template parameters.
Data Binding & Forms
EditForm with Validation
<EditForm Model="model" OnValidSubmit="HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger" />
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<InputText id="email" class="form-control" @bind-Value="model.Email" />
<ValidationMessage For="@(() => model.Email)" class="text-danger" />
</div>
<button type="submit" class="btn btn-primary" disabled="@submitting">
@(submitting ? "Saving..." : "Save")
</button>
</EditForm>
Routing
Route Parameters and Constraints
@page "/users/{Id:long}"
@page "/users/{Id:long}/edit"
@code {
[Parameter]
public long Id { get; set; }
}
Navigation
@inject NavigationManager Navigation
Navigation.NavigateTo("/users"); // Client-side navigation
Navigation.NavigateTo("/users/1", forceLoad: true); // Full page reload
Use <NavLink> with Match="NavLinkMatch.Prefix" or NavLinkMatch.All for active CSS state.
State Management
Observable State Service
public class AppState
{
private UserResponse? _currentUser;
public UserResponse? CurrentUser
{
get => _currentUser;
set { _currentUser = value; NotifyStateChanged(); }
}
public event Action? OnChange;
private void NotifyStateChanged() => OnChange?.Invoke();
}
Consuming State in Components
@inject AppState State
@implements IDisposable
<span>@State.CurrentUser?.FirstName</span>
@code {
protected override void OnInitialized()
{
State.OnChange += StateHasChanged;
}
public void Dispose()
{
State.OnChange -= StateHasChanged;
}
}
CascadingValue for Shared Data
Use <CascadingValue Value="@theme"> in layouts. Consume with [CascadingParameter] in children. Good for theme, auth state, and locale. Avoid for frequently changing data (triggers full subtree re-render).
JavaScript Interop
Calling JS from C#
@inject IJSRuntime JS
@code {
// Only call in OnAfterRenderAsync, not OnInitializedAsync
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var width = await JS.InvokeAsync<int>("blazorInterop.getWindowWidth");
}
}
}
Encapsulate JS interop behind service classes registered in DI. Use InvokeVoidAsync for calls without return values, InvokeAsync<T> for typed returns.
.NET 8 Blazor United
Interactive Render Modes
@* Static SSR (default -- no interactivity) *@
<StaticComponent />
@* Interactive Server (SignalR) *@
<InteractiveComponent @rendermode="InteractiveServer" />
@* Interactive WebAssembly *@
<InteractiveComponent @rendermode="InteractiveWebAssembly" />
@* Interactive Auto (starts Server, switches to WASM when downloaded) *@
<InteractiveComponent @rendermode="InteractiveAuto" />
Streaming Rendering
Add @attribute [StreamRendering] to pages with async data loading. The page renders immediately with placeholder content, then streams updates as OnInitializedAsync completes. Ideal for SSR pages loading slow data.
Testing Overview
Standards
- Use bUnit for component unit tests
- Use xUnit as the test runner
- Use Moq or NSubstitute for service mocking
- Use FluentAssertions for readable assertions
- Test rendered markup, parameter behavior, and event callbacks
- Coverage target: >80% for business logic components
Basic Component Test
using Bunit;
using FluentAssertions;
public class UserCardTests : TestContext
{
[Fact]
public void UserCard_DisplaysUserInfo()
{
var user = new UserResponse(1, "test@example.com", "John", "Doe",
"User", true, DateTime.UtcNow);
var cut = RenderComponent<UserCard>(p => p.Add(x => x.User, user));
cut.Find(".card-title").TextContent.Should().Contain("John Doe");
cut.Find("small.text-muted").TextContent.Should().Contain("test@example.com");
}
[Fact]
public void UserCard_EditButton_InvokesCallback()
{
var user = new UserResponse(1, "t@e.com", "J", "D", "User", true, DateTime.UtcNow);
var clicked = false;
var cut = RenderComponent<UserCard>(p => p
.Add(x => x.User, user)
.Add(x => x.OnEdit, EventCallback.Factory.Create(this, () => clicked = true)));
cut.Find("button.btn-outline-primary").Click();
clicked.Should().BeTrue();
}
}
Tooling
Essential Commands
dotnet new blazorwasm -o MyApp.Client # New Blazor WASM app
dotnet new blazorserver -o MyApp.Server # New Blazor Server app
dotnet new blazor -o MyApp # New Blazor Web App (.NET 8 United)
dotnet watch run --project MyApp.Client # Dev server with hot reload
dotnet build # Build
dotnet publish -c Release # Publish
dotnet test # Run tests
dotnet add package bunit # Add bUnit testing
Key Dependencies
| Package | Purpose |
|---|---|
Microsoft.AspNetCore.Components.WebAssembly |
WASM hosting |
Microsoft.AspNetCore.Components.WebAssembly.Authentication |
WASM auth |
Blazored.LocalStorage |
Browser local storage |
Blazored.Toast |
Toast notifications |
bunit |
Component testing |
Fluxor.Blazor.Web |
Redux-style state management |
Best Practices Summary
DO
- Use
@keyon list items for efficient DOM diffing - Use
EventCallbackoverActionfor component events - Implement
IDisposablefor event handler and timer cleanup - Use
<Virtualize>for rendering large collections - Use typed
HttpClientfor API calls - Use
CascadingValuefor widely shared state (theme, auth) - Use
@rendermodeper-component in .NET 8 United
DO NOT
- Call JS interop in
OnInitialized(useOnAfterRender) - Mutate
[Parameter]properties directly - Use synchronous HTTP calls
- Ignore component lifecycle (leaks events and timers)
- Overuse JS interop (prefer C# solutions)
- Skip loading/error state handling in data-fetching components
Advanced Topics
For detailed patterns and examples, see:
- references/patterns.md -- Forms, SignalR, authentication, testing, performance, error handling patterns