feat: Ingestion Pipeline Stabilization and WASM Service Proxies (#42)
This PR stabilizes the Nexus Ingestion Engine by implementing functional service proxies for the Blazor WASM client and refining the backend infrastructure for real-time progress tracking and database compatibility. ### Key Changes - **Infrastructure Stabilization**: - Implemented production-grade `EbookRepository` with PostgreSQL `EF.Functions.ILike` support. - Enforced `IsReadyForReading = false` state for newly added ebooks (resolves #35). - Updated `SignalRSyncBroadcaster` to support targeted user messaging and ingestion-specific progress updates (resolves #37). - **WASM Client Functional Proxies**: - Replaced "Throwing" dummy services with `WasmEbookRepository`, `WasmSyncBroadcaster`, `WasmBookStorageService`, and `WasmEmbeddingGenerator`. - These services proxy requests to the backend via a new set of Minimal API endpoints in `NexusReader.Web`. - **Domain Refinement**: - Added `IsReadyForReading` flag to the `Ebook` entity to manage background AI processing states. ### Related Issues - Fixes #35 - Fixes #36 - Fixes #37 --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #42 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #42.
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
# Blazor Authentication & Authorization
|
||||
|
||||
## Authentication Setup
|
||||
|
||||
### Blazor Server Setup
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add authentication
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.LogoutPath = "/logout";
|
||||
options.AccessDeniedPath = "/unauthorized";
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorizationCore();
|
||||
|
||||
// Add Blazor Server
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddServerSideBlazor();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Add authentication middleware BEFORE MapRazorPages
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapRazorPages();
|
||||
app.MapBlazorHub();
|
||||
```
|
||||
|
||||
### Blazor WebAssembly Setup
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.RootComponents.Add<App>("#app");
|
||||
|
||||
// Add authentication
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
|
||||
builder.Services.AddScoped<HttpClient>(sp =>
|
||||
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
// CustomAuthStateProvider
|
||||
public class CustomAuthStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly HttpClient httpClient;
|
||||
|
||||
public CustomAuthStateProvider(HttpClient httpClient)
|
||||
{
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await httpClient.GetJsonAsync<UserInfo>("/api/user");
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim(ClaimTypes.Name, user.Name),
|
||||
new Claim(ClaimTypes.Email, user.Email)
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "Custom");
|
||||
return new AuthenticationState(new ClaimsPrincipal(identity));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AuthorizeView Component
|
||||
|
||||
AuthorizeView displays content conditionally based on authorization status.
|
||||
|
||||
### Basic Authorization Check
|
||||
|
||||
```html
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<p>Hello, @context.User.Identity?.Name!</p>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<p>Please log in.</p>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
### Authorize by Role
|
||||
|
||||
```html
|
||||
<AuthorizeView Roles="Admin">
|
||||
<p>This content is only for Admins</p>
|
||||
</AuthorizeView>
|
||||
|
||||
<AuthorizeView Roles="User, Moderator">
|
||||
<p>User or Moderator content</p>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
### Authorize by Policy
|
||||
|
||||
```html
|
||||
<AuthorizeView Policy="ContentEditor">
|
||||
<p>Only content editors can see this</p>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
### Multiple AuthorizeView States
|
||||
|
||||
```html
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@if (context.User.IsInRole("Admin"))
|
||||
{
|
||||
<p>Admin dashboard</p>
|
||||
}
|
||||
else if (context.User.IsInRole("Editor"))
|
||||
{
|
||||
<p>Editor dashboard</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>User dashboard</p>
|
||||
}
|
||||
</Authorized>
|
||||
<Authorizing>
|
||||
<p>Checking authorization...</p>
|
||||
</Authorizing>
|
||||
<NotAuthorized>
|
||||
<p>Not authorized</p>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
### Authorize Multiple Resources
|
||||
|
||||
```html
|
||||
<AuthorizeView Context="Auth">
|
||||
<Authorized>
|
||||
<div>
|
||||
<h2>@Auth.User.Identity?.Name</h2>
|
||||
|
||||
@if (Auth.User.IsInRole("Admin"))
|
||||
{
|
||||
<a href="/admin">Admin Panel</a>
|
||||
}
|
||||
|
||||
@if (Auth.User.HasClaim("department", "engineering"))
|
||||
{
|
||||
<a href="/engineering">Engineering</a>
|
||||
}
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
```
|
||||
|
||||
## Authorize Attribute
|
||||
|
||||
Apply `[Authorize]` to pages to require authentication.
|
||||
|
||||
### Basic Page Authorization
|
||||
|
||||
```csharp
|
||||
@page "/admin"
|
||||
@attribute [Authorize]
|
||||
|
||||
<h2>Admin Page</h2>
|
||||
<p>Only authenticated users can see this.</p>
|
||||
```
|
||||
|
||||
### Role-Based Authorization
|
||||
|
||||
```csharp
|
||||
@page "/admin"
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
<h2>Admin Panel</h2>
|
||||
<p>Only admins can access this page.</p>
|
||||
```
|
||||
|
||||
### Policy-Based Authorization
|
||||
|
||||
```csharp
|
||||
@page "/dashboard"
|
||||
@attribute [Authorize(Policy = "RequireAdminRole")]
|
||||
|
||||
<h2>Dashboard</h2>
|
||||
```
|
||||
|
||||
### Multiple Requirements
|
||||
|
||||
```csharp
|
||||
@page "/admin"
|
||||
@attribute [Authorize(Roles = "Admin, Manager")]
|
||||
@attribute [Authorize(Policy = "ActiveSubscription")]
|
||||
|
||||
<h2>Admin Dashboard</h2>
|
||||
```
|
||||
|
||||
## Authorization Policies
|
||||
|
||||
Define fine-grained authorization policies.
|
||||
|
||||
### Setup Policies
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
builder.Services.AddAuthorizationCore(options =>
|
||||
{
|
||||
options.AddPolicy("RequireAdminRole", policy =>
|
||||
policy.RequireRole("Admin"));
|
||||
|
||||
options.AddPolicy("ActiveSubscription", policy =>
|
||||
policy.Requirements.Add(new ActiveSubscriptionRequirement()));
|
||||
|
||||
options.AddPolicy("ContentEditor", policy =>
|
||||
policy.RequireClaim("department", "engineering", "content"));
|
||||
|
||||
options.AddPolicy("AdultUser", policy =>
|
||||
policy.Requirements.Add(new MinimumAgeRequirement(18)));
|
||||
});
|
||||
|
||||
// Add custom policy handler
|
||||
builder.Services.AddSingleton<IAuthorizationHandler, ActiveSubscriptionHandler>();
|
||||
```
|
||||
|
||||
### Custom Policy Handlers
|
||||
|
||||
```csharp
|
||||
public class ActiveSubscriptionRequirement : IAuthorizationRequirement { }
|
||||
|
||||
public class ActiveSubscriptionHandler : AuthorizationHandler<ActiveSubscriptionRequirement>
|
||||
{
|
||||
private readonly IUserService userService;
|
||||
|
||||
public ActiveSubscriptionHandler(IUserService userService)
|
||||
{
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
ActiveSubscriptionRequirement requirement)
|
||||
{
|
||||
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await userService.GetUserAsync(userId);
|
||||
|
||||
if (user?.SubscriptionActive == true)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Fail();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class MinimumAgeRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public int MinimumAge { get; set; }
|
||||
|
||||
public MinimumAgeRequirement(int minimumAge)
|
||||
{
|
||||
MinimumAge = minimumAge;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Accessing Authentication State
|
||||
|
||||
### In Components
|
||||
|
||||
```csharp
|
||||
@page "/user-profile"
|
||||
|
||||
@if (authState == null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<h2>Welcome, @authState.User.Identity?.Name</h2>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Not authenticated</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private AuthenticationState? authState;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
authState = await AuthStateTask!;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Check Claims and Roles
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private async Task CheckUserAsync()
|
||||
{
|
||||
var authState = await AuthStateTask!;
|
||||
var user = authState.User;
|
||||
|
||||
if (user.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var name = user.Identity.Name;
|
||||
var email = user.FindFirst(ClaimTypes.Email)?.Value;
|
||||
var isAdmin = user.IsInRole("Admin");
|
||||
var department = user.FindFirst("department")?.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Login/Logout Implementation
|
||||
|
||||
### Login Page
|
||||
|
||||
```csharp
|
||||
@page "/login"
|
||||
@layout BlankLayout
|
||||
|
||||
<div class="login-form">
|
||||
<h2>Login</h2>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
|
||||
<EditForm Model="@model" OnValidSubmit="@HandleLoginAsync">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email:</label>
|
||||
<InputText @bind-Value="model.Email" class="form-control" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password:</label>
|
||||
<InputText @bind-Value="model.Password" type="password" class="form-control" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
private LoginModel model = new();
|
||||
private string? errorMessage;
|
||||
|
||||
private async Task HandleLoginAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await AuthService.LoginAsync(model.Email, model.Password);
|
||||
|
||||
// Update authentication state
|
||||
if (AuthStateProvider is CustomAuthStateProvider customAuth)
|
||||
{
|
||||
await customAuth.SetUserAsync(result.User);
|
||||
}
|
||||
|
||||
// Redirect to return URL or home
|
||||
var url = !string.IsNullOrEmpty(ReturnUrl) ? ReturnUrl : "/";
|
||||
Navigation.NavigateTo(url, forceLoad: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class LoginModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
```
|
||||
|
||||
### Logout Endpoint
|
||||
|
||||
```csharp
|
||||
// Pages/Logout.cshtml (in Blazor Server)
|
||||
@page "/logout"
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject SignInManager<IdentityUser> SignInManager
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await SignInManager.SignOutAsync();
|
||||
Navigation.NavigateTo("/");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Claims-Based Authorization
|
||||
|
||||
Working with claims for fine-grained authorization.
|
||||
|
||||
### Add Claims to User
|
||||
|
||||
```csharp
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim(ClaimTypes.Name, user.Name),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
new Claim("department", "engineering"),
|
||||
new Claim("level", "senior")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "Custom");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
```
|
||||
|
||||
### Check Claims in Component
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private async Task CheckDepartmentAsync()
|
||||
{
|
||||
var authState = await AuthStateTask!;
|
||||
var user = authState.User;
|
||||
|
||||
var department = user.FindFirst("department")?.Value;
|
||||
var level = user.FindFirst("level")?.Value;
|
||||
|
||||
if (department == "engineering")
|
||||
{
|
||||
// Show engineering-specific UI
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Use Cascading AuthenticationState
|
||||
|
||||
```csharp
|
||||
// App.razor - already cascades AuthenticationState by default
|
||||
<CascadingAuthenticationState>
|
||||
<Router ... />
|
||||
</CascadingAuthenticationState>
|
||||
```
|
||||
|
||||
### Always Check firstRender in OnAfterRender
|
||||
|
||||
```csharp
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Initialize only once
|
||||
authState = await AuthStateTask!;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use forceLoad for Logout
|
||||
|
||||
```csharp
|
||||
private async Task LogoutAsync()
|
||||
{
|
||||
await AuthService.LogoutAsync();
|
||||
// forceLoad clears client-side state
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
```
|
||||
|
||||
### Validate on Server
|
||||
|
||||
- Never trust client-side authorization
|
||||
- Always validate authorization on backend API
|
||||
- Check claims/roles on server methods
|
||||
|
||||
### Use ReturnUrl After Login
|
||||
|
||||
```csharp
|
||||
// Redirect back to originally-requested page
|
||||
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [routing-navigation.md](routing-navigation.md) for route-based authorization. See [components-lifecycle.md](components-lifecycle.md) for parameter security.
|
||||
@@ -0,0 +1,550 @@
|
||||
# Blazor Components & Component Lifecycle
|
||||
|
||||
## Component Structure
|
||||
|
||||
Components are the fundamental building blocks of Blazor applications. A Blazor component is a self-contained piece of UI with an optional logic.
|
||||
|
||||
### Basic Component Syntax
|
||||
|
||||
```csharp
|
||||
@page "/example"
|
||||
@using MyApp.Services
|
||||
@inject IMyService MyService
|
||||
|
||||
<h3>@Title</h3>
|
||||
<div>@ChildContent</div>
|
||||
<button @onclick="HandleClick">Click me</button>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "Default";
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private void HandleClick()
|
||||
{
|
||||
// Handle button click
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key elements:
|
||||
|
||||
- **`@page` directive**: Makes component routable (optional for non-page components)
|
||||
- **`@using`**: Import namespaces
|
||||
- **`@inject`**: Dependency injection
|
||||
- **HTML markup**: Regular HTML with Blazor directives
|
||||
- **`@code` block**: C# logic including lifecycle methods
|
||||
|
||||
### Component vs Page
|
||||
|
||||
- **Page Component**: Has `@page` directive, routable via URL
|
||||
- Example: `/Counter` route
|
||||
- Located in `Pages/` folder (convention)
|
||||
|
||||
- **Reusable Component**: No `@page` directive, used by other components
|
||||
- Example: `<UserCard @bind-User="user" />`
|
||||
- Located in `Shared/` or domain-specific folder
|
||||
|
||||
## Component Lifecycle
|
||||
|
||||
### Lifecycle Sequence
|
||||
|
||||
Component lifecycle methods execute in this order:
|
||||
|
||||
```
|
||||
1. SetParametersAsync()
|
||||
↓
|
||||
2. OnInitialized() or OnInitializedAsync()
|
||||
↓
|
||||
3. OnParametersSet() or OnParametersSetAsync()
|
||||
↓
|
||||
4. ShouldRender() [decision point - skip if returns false]
|
||||
↓
|
||||
5. OnAfterRender() or OnAfterRenderAsync()
|
||||
```
|
||||
|
||||
When parameters change (parent re-renders):
|
||||
|
||||
```
|
||||
SetParametersAsync() [parameters updated]
|
||||
↓
|
||||
OnParametersSet() [NOT OnInitialized - that runs once only]
|
||||
↓
|
||||
ShouldRender()
|
||||
↓
|
||||
OnAfterRender()
|
||||
```
|
||||
|
||||
### Lifecycle Methods Detailed
|
||||
|
||||
#### SetParametersAsync()
|
||||
|
||||
- **When**: First method called, before initialization
|
||||
- **Purpose**: Set component parameters
|
||||
- **Usage**: Rarely overridden, use OnInitialized instead
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
public override async Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
// Custom parameter processing if needed
|
||||
await base.SetParametersAsync(parameters);
|
||||
}
|
||||
```
|
||||
|
||||
#### OnInitialized / OnInitializedAsync()
|
||||
|
||||
- **When**: Once per component lifetime, after parameters set
|
||||
- **Purpose**: Initialize component state, load data
|
||||
- **Runs**: Only ONCE, even if parameters change
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
data = await Service.LoadDataAsync();
|
||||
}
|
||||
```
|
||||
|
||||
**Common Uses:**
|
||||
|
||||
- Load initial data from API
|
||||
- Set up subscriptions
|
||||
- Initialize state based on parameters
|
||||
|
||||
#### OnParametersSet / OnParametersSetAsync()
|
||||
|
||||
- **When**: After parameters set, runs EVERY time parameters change
|
||||
- **Purpose**: React to parameter changes
|
||||
- **Runs**: Every time parent re-renders with different values
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
if (UserId != previousUserId)
|
||||
{
|
||||
data = await Service.LoadUserDataAsync(UserId);
|
||||
previousUserId = UserId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Common Uses:**
|
||||
|
||||
- Update UI based on new parameter values
|
||||
- Fetch new data when ID parameter changes
|
||||
- React to cascading parameter changes
|
||||
|
||||
#### ShouldRender()
|
||||
|
||||
- **When**: Before DOM rendering, decision point
|
||||
- **Purpose**: Optimize rendering by skipping unnecessary renders
|
||||
- **Returns**: true (render) or false (skip)
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
protected override bool ShouldRender()
|
||||
{
|
||||
// Only render if specific field changed
|
||||
return hasChanged;
|
||||
}
|
||||
```
|
||||
|
||||
**Common Optimizations:**
|
||||
|
||||
- Skip render if data unchanged
|
||||
- Prevent re-render from external events
|
||||
- Implement custom change detection
|
||||
|
||||
#### OnAfterRender / OnAfterRenderAsync()
|
||||
|
||||
- **When**: After component rendered to DOM
|
||||
- **Purpose**: Work with DOM, initialize JS libraries, final setup
|
||||
- **Parameter**: `firstRender` - true only on first render
|
||||
- **Code Example**:
|
||||
|
||||
```csharp
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Initialize JS library only once
|
||||
await JS.InvokeVoidAsync("initializeChart", elementRef);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical Use Case:**
|
||||
|
||||
```csharp
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
// Load JS module
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./scripts/myScript.js");
|
||||
|
||||
// Initialize library
|
||||
await module.InvokeVoidAsync("setupChart", element);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Always use `firstRender` check for one-time initialization. This prevents re-initializing on every parameter change.
|
||||
|
||||
## Component Parameters
|
||||
|
||||
### Parameter Declaration
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
// Simple parameter
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "Default";
|
||||
|
||||
// Required parameter (C# 11+)
|
||||
[Parameter, EditorRequired]
|
||||
public int UserId { get; set; }
|
||||
|
||||
// Child content
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
// Event callback
|
||||
[Parameter]
|
||||
public EventCallback<string> OnValueChanged { get; set; }
|
||||
|
||||
// Cascading parameter
|
||||
[CascadingParameter]
|
||||
public ThemeInfo? CurrentTheme { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Parameter Best Practices
|
||||
|
||||
**Use Clear Names:**
|
||||
|
||||
```csharp
|
||||
// ✅ Good - clear intent
|
||||
[Parameter]
|
||||
public bool IsVisible { get; set; }
|
||||
|
||||
// ❌ Poor - ambiguous
|
||||
[Parameter]
|
||||
public bool State { get; set; }
|
||||
```
|
||||
|
||||
**Use Nullable Types for Optional:**
|
||||
|
||||
```csharp
|
||||
// ✅ Good - nullable indicates optional
|
||||
[Parameter]
|
||||
public string? OptionalValue { get; set; }
|
||||
|
||||
// ✅ Good - default value
|
||||
[Parameter]
|
||||
public int MaxItems { get; set; } = 10;
|
||||
|
||||
// ❌ Poor - not clear if optional
|
||||
[Parameter]
|
||||
public string RequiredValue { get; set; }
|
||||
```
|
||||
|
||||
**Use [EditorRequired] for Required Parameters (C# 11+):**
|
||||
|
||||
```csharp
|
||||
// ✅ Best practice - compiler enforces, IDE warns
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = default!;
|
||||
|
||||
// Fallback for older C#
|
||||
[Parameter]
|
||||
public string Title { get; set; } = default!;
|
||||
```
|
||||
|
||||
**Use EventCallback for Child-to-Parent Communication:**
|
||||
|
||||
```csharp
|
||||
// ✅ Correct - EventCallback for async safety
|
||||
[Parameter]
|
||||
public EventCallback<string> OnValueChanged { get; set; }
|
||||
|
||||
// ✅ With custom args
|
||||
[Parameter]
|
||||
public EventCallback<ValueChangeEventArgs> OnValueChanged { get; set; }
|
||||
|
||||
// ❌ Avoid - direct Action, not async-safe
|
||||
[Parameter]
|
||||
public Action<string>? OnValueChanged { get; set; }
|
||||
```
|
||||
|
||||
### Parameter Change Detection
|
||||
|
||||
To know when a parameter changed:
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private int previousUserId;
|
||||
|
||||
[Parameter]
|
||||
public int UserId { get; set; }
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (UserId != previousUserId)
|
||||
{
|
||||
previousUserId = UserId;
|
||||
await LoadUserData();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use a comparison strategy:
|
||||
|
||||
```csharp
|
||||
private object? previousCriteria;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var currentCriteria = (SearchId, SearchTerm);
|
||||
|
||||
if (!Equals(previousCriteria, currentCriteria))
|
||||
{
|
||||
previousCriteria = currentCriteria;
|
||||
await PerformSearch();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cascading Values
|
||||
|
||||
Cascading values allow ancestor components to provide data to all descendants without explicit parameter passing.
|
||||
|
||||
### Providing Cascading Values
|
||||
|
||||
```csharp
|
||||
<!-- Parent component -->
|
||||
<CascadingValue Value="@currentUser">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private User currentUser = new();
|
||||
}
|
||||
```
|
||||
|
||||
### Receiving Cascading Values
|
||||
|
||||
```csharp
|
||||
<!-- Child component anywhere in hierarchy -->
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public User? CurrentUser { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (CurrentUser == null)
|
||||
{
|
||||
// Handle missing cascading value
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Cascading Values
|
||||
|
||||
```csharp
|
||||
<!-- Provider -->
|
||||
<CascadingValue Value="@theme">
|
||||
<CascadingValue Value="@currentUser">
|
||||
<CascadingValue Value="@permissions">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
|
||||
<!-- Consumer - multiple parameters -->
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public Theme? Theme { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public User? CurrentUser { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public Permissions? Permissions { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Named Cascading Values
|
||||
|
||||
For disambiguation when multiple values of same type:
|
||||
|
||||
```csharp
|
||||
<!-- Provider -->
|
||||
<CascadingValue Value="@themeLight" Name="Light">
|
||||
<CascadingValue Value="@themeDark" Name="Dark">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
|
||||
<!-- Consumer -->
|
||||
@code {
|
||||
[CascadingParameter(Name = "Light")]
|
||||
public Theme? LightTheme { get; set; }
|
||||
|
||||
[CascadingParameter(Name = "Dark")]
|
||||
public Theme? DarkTheme { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
## RenderFragment for Component Composition
|
||||
|
||||
RenderFragment enables flexible component composition.
|
||||
|
||||
### Basic RenderFragment
|
||||
|
||||
```csharp
|
||||
<!-- Parent component -->
|
||||
<div>
|
||||
<h2>Header</h2>
|
||||
@ChildContent
|
||||
<footer>Footer</footer>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
|
||||
<!-- Usage -->
|
||||
<Layout>
|
||||
<p>This is the main content</p>
|
||||
</Layout>
|
||||
```
|
||||
|
||||
### Typed RenderFragment with Context
|
||||
|
||||
```csharp
|
||||
<!-- ListComponent.razor -->
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
@ItemTemplate(item)
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public IEnumerable<Item> Items { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment<Item>? ItemTemplate { get; set; }
|
||||
}
|
||||
|
||||
<!-- Usage -->
|
||||
<ListComponent Items="@items">
|
||||
<ItemTemplate Context="item">
|
||||
<div>@item.Name - @item.Price</div>
|
||||
</ItemTemplate>
|
||||
</ListComponent>
|
||||
```
|
||||
|
||||
### Multiple Named Content Areas
|
||||
|
||||
```csharp
|
||||
<!-- Card component with multiple slots -->
|
||||
<div class="card">
|
||||
<div class="card-header">@Header</div>
|
||||
<div class="card-body">@Body</div>
|
||||
<div class="card-footer">@Footer</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? Header { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Body { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Footer { get; set; }
|
||||
}
|
||||
|
||||
<!-- Usage -->
|
||||
<Card>
|
||||
<Header>
|
||||
<h3>Card Title</h3>
|
||||
</Header>
|
||||
<Body>
|
||||
<p>Card content</p>
|
||||
</Body>
|
||||
<Footer>
|
||||
<button>Action</button>
|
||||
</Footer>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Component Best Practices
|
||||
|
||||
### Single Responsibility
|
||||
|
||||
- Each component should have one clear purpose
|
||||
- Avoid god components that do too much
|
||||
- Example: `UserProfile` component should focus on displaying user info, not handle complex business logic
|
||||
|
||||
### Composition Over Inheritance
|
||||
|
||||
- Use cascading values for shared state, not deep hierarchies
|
||||
- Compose components rather than creating base classes
|
||||
- Example: Create theme provider component instead of theme-aware base class
|
||||
|
||||
### Keep Components Simple
|
||||
|
||||
- Minimize `@code` block logic
|
||||
- Move complex logic to services
|
||||
- Example: Validation logic → ValidationService, not in component
|
||||
|
||||
### Proper Disposal
|
||||
|
||||
- Implement `IDisposable` or `IAsyncDisposable`
|
||||
- Unsubscribe from events
|
||||
- Dispose timers and resources
|
||||
|
||||
```csharp
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
private IJSObjectReference? module;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./myScript.js");
|
||||
}
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
if (module is not null)
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [state-management-events.md](state-management-events.md) for event handling and state updates. See [performance-advanced.md](performance-advanced.md) for optimization techniques.
|
||||
@@ -0,0 +1,589 @@
|
||||
# Blazor Forms & Validation
|
||||
|
||||
## EditForm Component
|
||||
|
||||
EditForm provides a complete form handling solution with data binding and validation.
|
||||
|
||||
### Basic EditForm
|
||||
|
||||
```csharp
|
||||
@page "/register"
|
||||
|
||||
<EditForm Model="@model" OnValidSubmit="@HandleValidSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary />
|
||||
|
||||
<div class="form-group">
|
||||
<label>Name:</label>
|
||||
<InputText @bind-Value="model.Name" class="form-control" />
|
||||
<ValidationMessage For="@(() => model.Name)" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email:</label>
|
||||
<InputText @bind-Value="model.Email" class="form-control" />
|
||||
<ValidationMessage For="@(() => model.Email)" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">Register</button>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private RegistrationModel model = new();
|
||||
|
||||
private async Task HandleValidSubmit()
|
||||
{
|
||||
// Form is valid, process data
|
||||
await Service.RegisterUserAsync(model);
|
||||
}
|
||||
}
|
||||
|
||||
public class RegistrationModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100, MinimumLength = 2)]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
```
|
||||
|
||||
### EditForm Events
|
||||
|
||||
```csharp
|
||||
<EditForm Model="@model"
|
||||
OnValidSubmit="@OnValidSubmit"
|
||||
OnInvalidSubmit="@OnInvalidSubmit"
|
||||
OnSubmit="@OnSubmit">
|
||||
<!-- Form content -->
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private async Task OnValidSubmit()
|
||||
{
|
||||
// Fires when form is valid and submitted
|
||||
}
|
||||
|
||||
private async Task OnInvalidSubmit()
|
||||
{
|
||||
// Fires when form is invalid and submitted
|
||||
}
|
||||
|
||||
private async Task OnSubmit()
|
||||
{
|
||||
// Fires for any submit (valid or invalid)
|
||||
// Useful for custom validation logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Form State Control
|
||||
|
||||
```csharp
|
||||
@inject EditFormService FormService
|
||||
|
||||
<EditForm Model="@model" @ref="form">
|
||||
<!-- Form content -->
|
||||
</EditForm>
|
||||
|
||||
<button @onclick="Submit">Submit</button>
|
||||
<button @onclick="Reset">Reset</button>
|
||||
<button @onclick="CheckValid">Is Valid?</button>
|
||||
|
||||
@code {
|
||||
private EditForm? form;
|
||||
private UserModel model = new();
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
// Manually trigger validation and submission
|
||||
await form!.RequestValidationAsync();
|
||||
|
||||
// Check if valid
|
||||
if (form!.EditContext.IsModified() && form!.EditContext.Validate())
|
||||
{
|
||||
// Process form
|
||||
}
|
||||
}
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
// Reset all fields to default
|
||||
form!.EditContext.ResetEditingItemAsync();
|
||||
}
|
||||
|
||||
private void CheckValid()
|
||||
{
|
||||
bool isValid = form!.EditContext.Validate();
|
||||
Console.WriteLine($"Form valid: {isValid}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Input Components
|
||||
|
||||
### Text Input
|
||||
|
||||
```csharp
|
||||
<InputText @bind-Value="model.Name" class="form-control" />
|
||||
<InputTextArea @bind-Value="model.Description" rows="4" />
|
||||
|
||||
@code {
|
||||
private UserModel model = new();
|
||||
}
|
||||
```
|
||||
|
||||
### Numeric Input
|
||||
|
||||
```csharp
|
||||
<InputNumber @bind-Value="model.Age" class="form-control" />
|
||||
<InputNumber @bind-Value="model.Price" @bind-Value:format="N2" />
|
||||
|
||||
@code {
|
||||
private int age;
|
||||
private decimal price;
|
||||
}
|
||||
```
|
||||
|
||||
**Format specifiers:**
|
||||
|
||||
- `N2` - Number with 2 decimal places
|
||||
- `C` - Currency
|
||||
- `P` - Percentage
|
||||
- `D` - Date
|
||||
- `X` - Hexadecimal
|
||||
|
||||
### Date Input
|
||||
|
||||
```csharp
|
||||
<InputDate @bind-Value="model.BirthDate" />
|
||||
<InputDate @bind-Value="model.StartTime" Type="InputDateType.DateTimeLocal" />
|
||||
|
||||
@code {
|
||||
private DateTime birthDate;
|
||||
private DateTime startTime;
|
||||
}
|
||||
```
|
||||
|
||||
**Types:**
|
||||
|
||||
- `InputDateType.Date` - Date only (default)
|
||||
- `InputDateType.DateTimeLocal` - Date and time
|
||||
- `InputDateType.Month` - Month and year
|
||||
- `InputDateType.Time` - Time only
|
||||
|
||||
### Select/Dropdown
|
||||
|
||||
```csharp
|
||||
<InputSelect @bind-Value="model.Category" class="form-control">
|
||||
<option value="">Select a category...</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="clothing">Clothing</option>
|
||||
</InputSelect>
|
||||
|
||||
<!-- Dynamic options from data -->
|
||||
<InputSelect @bind-Value="model.CategoryId">
|
||||
<option value="">Select...</option>
|
||||
@foreach (var cat in categories)
|
||||
{
|
||||
<option value="@cat.Id">@cat.Name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
|
||||
@code {
|
||||
private string selectedCategory = "";
|
||||
private List<Category> categories = [];
|
||||
}
|
||||
```
|
||||
|
||||
### Checkbox
|
||||
|
||||
```csharp
|
||||
<InputCheckbox @bind-Value="model.AgreeToTerms" />
|
||||
Accept terms of service?
|
||||
|
||||
@code {
|
||||
private bool agreeToTerms = false;
|
||||
}
|
||||
```
|
||||
|
||||
### Radio Buttons
|
||||
|
||||
```csharp
|
||||
<InputRadioGroup @bind-Value="model.Preference">
|
||||
<div>
|
||||
<InputRadio Value="@("option1")" />
|
||||
<label>Option 1</label>
|
||||
</div>
|
||||
<div>
|
||||
<InputRadio Value="@("option2")" />
|
||||
<label>Option 2</label>
|
||||
</div>
|
||||
</InputRadioGroup>
|
||||
|
||||
@code {
|
||||
private string preference = "option1";
|
||||
}
|
||||
```
|
||||
|
||||
### File Upload
|
||||
|
||||
```csharp
|
||||
<InputFile OnChange="@HandleFileSelect" />
|
||||
|
||||
@code {
|
||||
private async Task HandleFileSelect(InputFileChangeEventArgs e)
|
||||
{
|
||||
var file = e.File;
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var buffer = new byte[stream.Length];
|
||||
await stream.ReadAsync(buffer);
|
||||
|
||||
// Process file
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### DataAnnotations Validation
|
||||
|
||||
```csharp
|
||||
public class UserModel
|
||||
{
|
||||
[Required(ErrorMessage = "Name is required")]
|
||||
[StringLength(100, MinimumLength = 2)]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[EmailAddress(ErrorMessage = "Invalid email format")]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Range(18, 120, ErrorMessage = "Age must be 18-120")]
|
||||
public int Age { get; set; }
|
||||
|
||||
[Url]
|
||||
public string? Website { get; set; }
|
||||
|
||||
[Phone]
|
||||
public string? PhoneNumber { get; set; }
|
||||
|
||||
[CreditCard]
|
||||
public string? CardNumber { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Common Validators:**
|
||||
|
||||
- `[Required]` - Field must have value
|
||||
- `[StringLength(max)]` - Max length
|
||||
- `[StringLength(max, MinimumLength = min)]` - Min and max
|
||||
- `[EmailAddress]` - Valid email format
|
||||
- `[Range(min, max)]` - Numeric range
|
||||
- `[Url]` - Valid URL format
|
||||
- `[Phone]` - Valid phone format
|
||||
- `[CreditCard]` - Valid credit card format
|
||||
- `[RegularExpression(pattern)]` - Regex match
|
||||
|
||||
### ValidationSummary
|
||||
|
||||
Shows all validation errors for the form:
|
||||
|
||||
```csharp
|
||||
<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary />
|
||||
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<InputText @bind-Value="model.Email" />
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</EditForm>
|
||||
```
|
||||
|
||||
Displays as:
|
||||
|
||||
```
|
||||
- Name is required
|
||||
- Email is required
|
||||
```
|
||||
|
||||
### ValidationMessage
|
||||
|
||||
Shows validation error for specific field:
|
||||
|
||||
```csharp
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<ValidationMessage For="@(() => model.Name)" />
|
||||
|
||||
<!-- Custom CSS class -->
|
||||
<ValidationMessage For="@(() => model.Email)" class="text-danger" />
|
||||
```
|
||||
|
||||
### Custom Validation
|
||||
|
||||
Implement `IValidatableObject` for complex validation rules:
|
||||
|
||||
```csharp
|
||||
public class UserModel : IValidatableObject
|
||||
{
|
||||
public string Email { get; set; } = "";
|
||||
public string ConfirmEmail { get; set; } = "";
|
||||
|
||||
[Range(18, 100)]
|
||||
public int Age { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
// Compare email fields
|
||||
if (Email != ConfirmEmail)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Email addresses must match",
|
||||
new[] { nameof(ConfirmEmail) }
|
||||
);
|
||||
}
|
||||
|
||||
// Custom age validation
|
||||
if (Age > 0 && Age < 18 && HasRestrictedContent)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Users under 18 cannot access this content",
|
||||
new[] { nameof(Age) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasRestrictedContent { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Validators
|
||||
|
||||
Create reusable custom validators:
|
||||
|
||||
```csharp
|
||||
public class MinimumAgeAttribute : ValidationAttribute
|
||||
{
|
||||
private readonly int _minimumAge;
|
||||
|
||||
public MinimumAgeAttribute(int minimumAge)
|
||||
{
|
||||
_minimumAge = minimumAge;
|
||||
}
|
||||
|
||||
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
|
||||
{
|
||||
if (value is DateTime birthDate)
|
||||
{
|
||||
var age = DateTime.Today.Year - birthDate.Year;
|
||||
if (birthDate.Date > DateTime.Today.AddYears(-age)) age--;
|
||||
|
||||
if (age < _minimumAge)
|
||||
{
|
||||
return new ValidationResult($"Minimum age is {_minimumAge}");
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
public class UserModel
|
||||
{
|
||||
[MinimumAge(18)]
|
||||
public DateTime BirthDate { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Async Validation
|
||||
|
||||
```csharp
|
||||
public class UniqueEmailAttribute : ValidationAttribute
|
||||
{
|
||||
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
|
||||
{
|
||||
// Can't use async in ValidationAttribute
|
||||
// Use EditContext instead (see below)
|
||||
return ValidationResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// Better approach: Manual validation in component
|
||||
@code {
|
||||
private async Task HandleValidSubmit()
|
||||
{
|
||||
// Check email availability before submit
|
||||
bool isUnique = await Service.IsEmailUniqueAsync(model.Email);
|
||||
if (!isUnique)
|
||||
{
|
||||
form!.EditContext.AddValidationMessages(
|
||||
FieldIdentifier.Create(() => model.Email),
|
||||
new[] { "Email is already registered" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await SaveUserAsync(model);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Form Patterns
|
||||
|
||||
### Loading State
|
||||
|
||||
```csharp
|
||||
@if (isSubmitting)
|
||||
{
|
||||
<p>Saving...</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="@model" OnValidSubmit="@SubmitAsync">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary />
|
||||
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<button type="submit" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "Saving..." : "Submit")
|
||||
</button>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool isSubmitting;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
isSubmitting = true;
|
||||
try
|
||||
{
|
||||
await Service.SaveAsync(model);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```csharp
|
||||
<EditForm Model="@model" OnValidSubmit="@SubmitAsync">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
|
||||
<ValidationSummary />
|
||||
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<button type="submit">Submit</button>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private string? errorMessage;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
errorMessage = null;
|
||||
await Service.SaveAsync(model);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Step Form
|
||||
|
||||
```csharp
|
||||
@page "/wizard"
|
||||
|
||||
@if (currentStep == 1)
|
||||
{
|
||||
<h2>Step 1: Basic Info</h2>
|
||||
<InputText @bind-Value="model.Name" />
|
||||
<button @onclick="NextStep">Next</button>
|
||||
}
|
||||
else if (currentStep == 2)
|
||||
{
|
||||
<h2>Step 2: Contact Info</h2>
|
||||
<InputText @bind-Value="model.Email" />
|
||||
<button @onclick="PreviousStep">Back</button>
|
||||
<button @onclick="NextStep">Next</button>
|
||||
}
|
||||
else if (currentStep == 3)
|
||||
{
|
||||
<h2>Step 3: Confirm</h2>
|
||||
<p>Name: @model.Name</p>
|
||||
<p>Email: @model.Email</p>
|
||||
<button @onclick="PreviousStep">Back</button>
|
||||
<button @onclick="SubmitAsync">Submit</button>
|
||||
}
|
||||
|
||||
@code {
|
||||
private int currentStep = 1;
|
||||
private UserModel model = new();
|
||||
|
||||
private void NextStep() => currentStep++;
|
||||
private void PreviousStep() => currentStep--;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
await Service.RegisterAsync(model);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Real-Time Field Validation
|
||||
|
||||
```csharp
|
||||
<input @bind="email" @bind:event="oninput" @onblur="ValidateEmail" />
|
||||
@if (!string.IsNullOrEmpty(emailError))
|
||||
{
|
||||
<span class="error">@emailError</span>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string email = "";
|
||||
private string? emailError;
|
||||
|
||||
private void ValidateEmail()
|
||||
{
|
||||
if (string.IsNullOrEmpty(email))
|
||||
{
|
||||
emailError = "Email is required";
|
||||
}
|
||||
else if (!email.Contains("@"))
|
||||
{
|
||||
emailError = "Invalid email format";
|
||||
}
|
||||
else
|
||||
{
|
||||
emailError = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [state-management-events.md](state-management-events.md) for data binding patterns. See [authentication-authorization.md](authentication-authorization.md) for role-based form customization.
|
||||
@@ -0,0 +1,561 @@
|
||||
# Blazor Performance & Advanced Patterns
|
||||
|
||||
## Rendering Optimization
|
||||
|
||||
### ShouldRender Override
|
||||
|
||||
Control when components re-render to prevent unnecessary rendering cycles.
|
||||
|
||||
```csharp
|
||||
@page "/optimized"
|
||||
|
||||
<button @onclick="IncrementCount">Clicked @count times</button>
|
||||
<ChildComponent Value="@value" />
|
||||
|
||||
@code {
|
||||
private int count = 0;
|
||||
private string value = "test";
|
||||
|
||||
protected override bool ShouldRender()
|
||||
{
|
||||
// Only render if value changed, not if count changed
|
||||
// This component doesn't display count directly
|
||||
return false; // Skip render
|
||||
}
|
||||
|
||||
private void IncrementCount()
|
||||
{
|
||||
count++;
|
||||
// Component won't re-render, child component won't re-render either
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tracking Changed Fields
|
||||
|
||||
```csharp
|
||||
@page "/tracker"
|
||||
|
||||
<button @onclick="UpdateName">Update Name</button>
|
||||
<button @onclick="UpdateAge">Update Age</button>
|
||||
|
||||
<p>Name: @name</p>
|
||||
<p>Age: @age</p>
|
||||
|
||||
@code {
|
||||
private string? name;
|
||||
private int age;
|
||||
private bool nameChanged = false;
|
||||
private bool ageChanged = false;
|
||||
|
||||
protected override bool ShouldRender()
|
||||
{
|
||||
if (!nameChanged && !ageChanged)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
nameChanged = false;
|
||||
ageChanged = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateName()
|
||||
{
|
||||
name = "New Name";
|
||||
nameChanged = true;
|
||||
}
|
||||
|
||||
private void UpdateAge()
|
||||
{
|
||||
age = 30;
|
||||
ageChanged = true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Directive for List Items
|
||||
|
||||
```csharp
|
||||
@page "/list"
|
||||
|
||||
<button @onclick="AddItem">Add Item</button>
|
||||
|
||||
@foreach (var item in items)
|
||||
{
|
||||
<!-- WITHOUT @key - new ItemComponent created for each item -->
|
||||
<!-- <ItemComponent Item="@item" />-->
|
||||
|
||||
<!-- WITH @key - same ItemComponent reused if item.Id stays in list -->
|
||||
<ItemComponent @key="item.Id" Item="@item" />
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Item> items = [];
|
||||
|
||||
private void AddItem()
|
||||
{
|
||||
items = items.Prepend(new Item { Id = Guid.NewGuid(), Name = "New" }).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public class Item
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Why @key matters:**
|
||||
|
||||
- Helps Blazor's diffing algorithm match old components to new items
|
||||
- Prevents component state loss during list reordering
|
||||
- Improves performance with large lists
|
||||
|
||||
### IDisposable for Cleanup
|
||||
|
||||
```csharp
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
private IJSObjectReference? module;
|
||||
private Timer? timer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
timer = new Timer(_ => UpdateAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./myScript.js");
|
||||
}
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
timer?.Dispose();
|
||||
|
||||
if (module is not null)
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Virtualization
|
||||
|
||||
Virtualize large lists to render only visible items.
|
||||
|
||||
### Basic Virtualization
|
||||
|
||||
```csharp
|
||||
@page "/large-list"
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
|
||||
<Virtualize Items="@largeList" Context="item">
|
||||
<div class="item">
|
||||
<p>@item.Id - @item.Name</p>
|
||||
</div>
|
||||
</Virtualize>
|
||||
|
||||
@code {
|
||||
private List<Item> largeList = [];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Generate 100,000 items
|
||||
largeList = Enumerable.Range(1, 100000)
|
||||
.Select(i => new Item { Id = i, Name = $"Item {i}" })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Virtualization (Infinite Scroll)
|
||||
|
||||
```csharp
|
||||
@page "/infinite-scroll"
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
|
||||
<Virtualize ItemsProvider="@LoadItems" Context="item" OverscanCount="5">
|
||||
<div>@item.Name</div>
|
||||
</Virtualize>
|
||||
|
||||
@code {
|
||||
private async ValueTask<ItemsProviderResult<Item>> LoadItems(
|
||||
ItemsProviderRequest request)
|
||||
{
|
||||
// Simulate loading from server
|
||||
var startIndex = request.StartIndex;
|
||||
var count = request.Count;
|
||||
|
||||
var items = await Service.GetItemsAsync(startIndex, count);
|
||||
|
||||
// Return items and total count for scrollbar sizing
|
||||
return new ItemsProviderResult<Item>(items, totalItemCount: 1000000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `Items` - Static list of items to virtualize
|
||||
- `ItemsProvider` - Async method to load items on demand
|
||||
- `OverscanCount` - Extra items to render outside viewport (default 3)
|
||||
- `ItemSize` - Estimated height for scrollbar calculation
|
||||
|
||||
## JavaScript Interop
|
||||
|
||||
### Invoke JavaScript from C #
|
||||
|
||||
```csharp
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<button @onclick="CallJavaScript">Click me</button>
|
||||
|
||||
@code {
|
||||
private async Task CallJavaScript()
|
||||
{
|
||||
// Simple call - no return value
|
||||
await JS.InvokeVoidAsync("console.log", "Hello from Blazor");
|
||||
|
||||
// With return value
|
||||
var result = await JS.InvokeAsync<string>("myFunction", arg1, arg2);
|
||||
|
||||
// Generic call with any return type
|
||||
var data = await JS.InvokeAsync<Data>("loadData");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JS Module Isolation (Recommended)
|
||||
|
||||
```csharp
|
||||
// Component.razor
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div @ref="element">
|
||||
<canvas id="chart"></canvas>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ElementReference element;
|
||||
private IJSObjectReference? module;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Import JS module
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./scripts/chart.js");
|
||||
|
||||
// Call exported function
|
||||
await module.InvokeVoidAsync("initChart", element);
|
||||
}
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
if (module is not null)
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* scripts/chart.js */
|
||||
export function initChart(element) {
|
||||
const canvas = element.querySelector('#chart');
|
||||
// Initialize chart library
|
||||
}
|
||||
```
|
||||
|
||||
### Invoke C# from JavaScript
|
||||
|
||||
```csharp
|
||||
// Component.razor
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<button @onclick="SetupInterop">Setup</button>
|
||||
|
||||
@code {
|
||||
private IJSObjectReference? module;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./scripts/interop.js");
|
||||
|
||||
// Pass C# object reference to JS
|
||||
var objRef = DotNetObjectReference.Create(this);
|
||||
await module.InvokeVoidAsync("setupInterop", objRef);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task HandleJSEvent(string data)
|
||||
{
|
||||
Console.WriteLine($"JS called C#: {data}");
|
||||
// Update component state
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
if (module is not null)
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* scripts/interop.js */
|
||||
let dotnetHelper;
|
||||
|
||||
export function setupInterop(dotnetRef) {
|
||||
dotnetHelper = dotnetRef;
|
||||
|
||||
// Call C# method from JS
|
||||
document.addEventListener('click', async (e) => {
|
||||
await dotnetHelper.invokeMethodAsync('HandleJSEvent', 'User clicked');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling in Interop
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private async Task SafeInvokeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("riskyFunction");
|
||||
}
|
||||
catch (JSException jsEx)
|
||||
{
|
||||
Console.WriteLine($"JS error: {jsEx.Message}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine("JS call was cancelled");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
Load assemblies and components on demand.
|
||||
|
||||
### Lazy-Loaded Component Routes
|
||||
|
||||
```csharp
|
||||
<!-- App.razor -->
|
||||
<Router AppAssembly="@typeof(App).Assembly"
|
||||
AdditionalAssemblies="@additionalAssemblies"
|
||||
OnNavigateAsync="@OnNavigateAsync">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<p>Loading...</p>
|
||||
</NotFound>
|
||||
</Router>
|
||||
|
||||
@code {
|
||||
private List<Assembly>? additionalAssemblies;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
additionalAssemblies = new();
|
||||
}
|
||||
|
||||
private async Task OnNavigateAsync(NavigationContext context)
|
||||
{
|
||||
// Load admin assembly only when accessing /admin
|
||||
if (context.Path.StartsWith("admin"))
|
||||
{
|
||||
var adminAssembly = await JS.InvokeAsync<byte[]>(
|
||||
"fetch", "./_framework/admin.wasm");
|
||||
|
||||
additionalAssemblies!.Add(Assembly.Load(adminAssembly));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WASM Performance Best Practices
|
||||
|
||||
### AOT Compilation
|
||||
|
||||
```xml
|
||||
<!-- .csproj -->
|
||||
<PropertyGroup>
|
||||
<RunAOTCompilation>true</RunAOTCompilation>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
- No JIT compilation at runtime
|
||||
- Faster startup time
|
||||
- ~20% larger download
|
||||
- Production recommended
|
||||
|
||||
### Trimming
|
||||
|
||||
```xml
|
||||
<!-- .csproj -->
|
||||
<PropertyGroup>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
- Removes unused code
|
||||
- ~40% smaller download
|
||||
- May cause runtime errors if reflection-based code removed
|
||||
- Test thoroughly in Release build
|
||||
|
||||
### Compression
|
||||
|
||||
```xml
|
||||
<!-- .csproj -->
|
||||
<PropertyGroup>
|
||||
<BlazorWebAssemblyEnableCompression>true</BlazorWebAssemblyEnableCompression>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
Server-side (in Program.cs):
|
||||
|
||||
```csharp
|
||||
app.UseResponseCompression();
|
||||
|
||||
builder.Services.AddResponseCompression(opts =>
|
||||
{
|
||||
opts.Filters.Add(new GzipCompressionProvider());
|
||||
opts.Filters.Add(new BrotliCompressionProvider());
|
||||
});
|
||||
```
|
||||
|
||||
### Minimize JavaScript Interop
|
||||
|
||||
```csharp
|
||||
// INEFFICIENT - Many JS calls
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
await JS.InvokeVoidAsync("updateUI", i);
|
||||
}
|
||||
|
||||
// EFFICIENT - Single JS call with batch data
|
||||
var updates = Enumerable.Range(0, 1000).ToList();
|
||||
await JS.InvokeVoidAsync("updateUIBatch", updates);
|
||||
```
|
||||
|
||||
## Error Boundaries
|
||||
|
||||
Handle component errors gracefully.
|
||||
|
||||
```csharp
|
||||
@page "/error-demo"
|
||||
|
||||
<ErrorBoundary>
|
||||
<ChildContent>
|
||||
<ChildComponent />
|
||||
</ChildContent>
|
||||
<ErrorContent Context="ex">
|
||||
<div class="alert alert-danger">
|
||||
<h4>Error</h4>
|
||||
<p>@ex.Message</p>
|
||||
<button @onclick="ResetError">Try Again</button>
|
||||
</div>
|
||||
</ErrorContent>
|
||||
</ErrorBoundary>
|
||||
|
||||
@code {
|
||||
private ErrorBoundary? errorBoundary;
|
||||
|
||||
private async Task ResetError()
|
||||
{
|
||||
await errorBoundary!.RecoverAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Isolation
|
||||
|
||||
Scope CSS to specific components.
|
||||
|
||||
```html
|
||||
<!-- MyComponent.razor -->
|
||||
<div class="container">
|
||||
<h1>@Title</h1>
|
||||
</div>
|
||||
|
||||
<!-- MyComponent.razor.css -->
|
||||
.container {
|
||||
background-color: blue;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No global namespace pollution
|
||||
- Component-specific styling
|
||||
- CSS automatically scoped to component
|
||||
- Compiled into assembly
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
### Performance
|
||||
|
||||
- Use `@key` on list items
|
||||
- Override `ShouldRender()` to prevent unnecessary renders
|
||||
- Use virtualization for large lists
|
||||
- Minimize JavaScript interop calls
|
||||
- Enable AOT compilation and trimming for WASM
|
||||
|
||||
### JavaScript Interop
|
||||
|
||||
- Use module isolation pattern
|
||||
- Always dispose JS module references
|
||||
- Handle JS exceptions properly
|
||||
- Only call JS in `OnAfterRender` with firstRender check
|
||||
- Minimize interop calls for performance
|
||||
|
||||
### Architecture
|
||||
|
||||
- Keep components simple and focused
|
||||
- Move logic to services
|
||||
- Use cascading values for shared state
|
||||
- Implement IDisposable for cleanup
|
||||
- Validate authorization on server side
|
||||
|
||||
### User Experience
|
||||
|
||||
- Show loading states during async operations
|
||||
- Provide error feedback
|
||||
- Use AuthorizeView for conditional rendering
|
||||
- Implement error boundaries
|
||||
- Test on slow connections
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [components-lifecycle.md](components-lifecycle.md) for component disposal patterns. See [state-management-events.md](state-management-events.md) for state update optimization.
|
||||
@@ -0,0 +1,492 @@
|
||||
# Blazor Routing & Navigation
|
||||
|
||||
## Route Definition
|
||||
|
||||
Routes map URL paths to Blazor components.
|
||||
|
||||
### Basic Route Definition
|
||||
|
||||
```csharp
|
||||
@page "/product"
|
||||
@page "/product/{id}"
|
||||
|
||||
<h3>Product: @Id</h3>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? Id { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- `@page` directive makes component routable
|
||||
- Parameter name in URL (`{id}`) must match parameter name in `@code` block
|
||||
- Multiple `@page` directives supported (same component, multiple routes)
|
||||
|
||||
### Route Parameters
|
||||
|
||||
```csharp
|
||||
@page "/product/{id}"
|
||||
<p>Product: @id</p>
|
||||
|
||||
@page "/category/{categoryId}/product/{productId}"
|
||||
<p>Category: @categoryId, Product: @productId</p>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? id { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? categoryId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? productId { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Parameter Matching:**
|
||||
- Blazor matches route segments to parameter names (case-insensitive)
|
||||
- `{id}` in route matches `Id` parameter
|
||||
- Extra parameters in URL are ignored
|
||||
|
||||
### Route Constraints
|
||||
|
||||
Route constraints enforce parameter type and format:
|
||||
|
||||
```csharp
|
||||
@page "/product/{id:int}" <!-- Integer only -->
|
||||
@page "/order/{orderId:long}" <!-- Long integer -->
|
||||
@page "/user/{id:guid}" <!-- GUID format -->
|
||||
@page "/article/{slug:string}" <!-- String (default) -->
|
||||
@page "/event/{date:datetime}" <!-- DateTime format -->
|
||||
@page "/price/{amount:decimal}" <!-- Decimal number -->
|
||||
@page "/flag/{active:bool}" <!-- Boolean -->
|
||||
@page "/value/{num:double}" <!-- Double/Float -->
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int id { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Guid id { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool active { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in Constraints:**
|
||||
- `:int` - Integer values
|
||||
- `:long` - Long integers
|
||||
- `:guid` - GUID format
|
||||
- `:bool` - Boolean
|
||||
- `:datetime` - DateTime format
|
||||
- `:decimal` - Decimal numbers
|
||||
- `:double` / `:float` - Floating point
|
||||
- `:string` - Any string (default)
|
||||
|
||||
### Optional Route Parameters
|
||||
|
||||
```csharp
|
||||
@page "/search"
|
||||
@page "/search/{searchTerm}"
|
||||
|
||||
<p>Search term: @(searchTerm ?? "All results")</p>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? searchTerm { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Catch-All Routes
|
||||
|
||||
```csharp
|
||||
@page "/{*pageRoute}"
|
||||
|
||||
<p>Page not found: @pageRoute</p>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? pageRoute { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
```csharp
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<button @onclick="GoHome">Go Home</button>
|
||||
<button @onclick="GoToUser">Go to User</button>
|
||||
|
||||
@code {
|
||||
private void GoHome()
|
||||
{
|
||||
Navigation.NavigateTo("/");
|
||||
}
|
||||
|
||||
private void GoToUser()
|
||||
{
|
||||
Navigation.NavigateTo("/user/123");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation with Options
|
||||
|
||||
```csharp
|
||||
// Replace browser history entry instead of adding new one
|
||||
Navigation.NavigateTo("/home", replace: true);
|
||||
|
||||
// Force full page reload from server
|
||||
Navigation.NavigateTo("/refresh", forceLoad: true);
|
||||
|
||||
// Combine options
|
||||
Navigation.NavigateTo("/new-page", replace: true, forceLoad: true);
|
||||
```
|
||||
|
||||
**When to use `forceLoad: true`:**
|
||||
- After logout to clear client-side state
|
||||
- Accessing completely different app
|
||||
- Clearing service worker cache
|
||||
- Full server-side initialization needed
|
||||
|
||||
### NavLink Component
|
||||
|
||||
NavLink automatically highlights active route:
|
||||
|
||||
```csharp
|
||||
<NavLink href="/home" Match="NavLinkMatch.All">
|
||||
<span class="icon">🏠</span> Home
|
||||
</NavLink>
|
||||
|
||||
<NavLink href="/products" Match="NavLinkMatch.Prefix">
|
||||
<span class="icon">📦</span> Products
|
||||
</NavLink>
|
||||
|
||||
<NavLink href="/about" Match="NavLinkMatch.None">
|
||||
About
|
||||
</NavLink>
|
||||
|
||||
@code {
|
||||
// CSS class applied to active NavLink: active
|
||||
}
|
||||
```
|
||||
|
||||
**Match options:**
|
||||
- `NavLinkMatch.All` - Exact URL match required
|
||||
- `NavLinkMatch.Prefix` - URL starts with href (default)
|
||||
- `NavLinkMatch.None` - Never highlights
|
||||
|
||||
**CSS:**
|
||||
```css
|
||||
a.active {
|
||||
color: white;
|
||||
background-color: blue;
|
||||
}
|
||||
```
|
||||
|
||||
### Listen to Location Changes
|
||||
|
||||
```csharp
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<p>Current location: @Navigation.Uri</p>
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Navigation.LocationChanged += LocationChanged;
|
||||
}
|
||||
|
||||
private void LocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"New location: {e.Location}");
|
||||
|
||||
// React to navigation
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Navigation.LocationChanged -= LocationChanged;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Query Strings
|
||||
|
||||
### Reading Query Parameters
|
||||
|
||||
```csharp
|
||||
@page "/search"
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<p>Search results for: @searchQuery</p>
|
||||
|
||||
@code {
|
||||
private string? searchQuery;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
searchQuery = query["q"];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:** `/search?q=blazor` → `searchQuery = "blazor"`
|
||||
|
||||
### Building Query Strings
|
||||
|
||||
```csharp
|
||||
private void Search(string term)
|
||||
{
|
||||
Navigation.NavigateTo($"/search?q={Uri.EscapeDataString(term)}");
|
||||
}
|
||||
|
||||
// Or use QueryHelpers (in .NET 6+)
|
||||
var query = new Dictionary<string, string>
|
||||
{
|
||||
{ "q", "blazor" },
|
||||
{ "page", "1" }
|
||||
};
|
||||
|
||||
var url = NavigationManager.GetUriWithQueryParameters("/search", query);
|
||||
Navigation.NavigateTo(url);
|
||||
```
|
||||
|
||||
### Multiple Query Parameters
|
||||
|
||||
```csharp
|
||||
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
|
||||
var category = query["category"];
|
||||
var page = int.TryParse(query["page"], out var p) ? p : 1;
|
||||
var sort = query["sort"] ?? "name";
|
||||
```
|
||||
|
||||
**Usage:** `/products?category=electronics&page=2&sort=price`
|
||||
|
||||
## Router Configuration
|
||||
|
||||
The Router component in `App.razor` configures routing:
|
||||
|
||||
```csharp
|
||||
<!-- App.razor -->
|
||||
<Router AppAssembly="@typeof(App).Assembly"
|
||||
AdditionalAssemblies="@additionalAssemblies"
|
||||
OnNavigateAsync="@OnNavigateAsync">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
||||
<PageTitle>@pageTitle</PageTitle>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(MainLayout)">
|
||||
<p>Page not found</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
|
||||
@code {
|
||||
private List<Assembly>? additionalAssemblies;
|
||||
private string pageTitle = "Loading...";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Load assemblies dynamically if needed
|
||||
additionalAssemblies = new List<Assembly>
|
||||
{
|
||||
typeof(SomeOtherAssembly).Assembly
|
||||
};
|
||||
}
|
||||
|
||||
private async Task OnNavigateAsync(NavigationContext context)
|
||||
{
|
||||
// Can be used for lazy loading assemblies
|
||||
// Not commonly needed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Layouts
|
||||
|
||||
Layouts are parent components that wrap pages.
|
||||
|
||||
### Define a Layout
|
||||
|
||||
```csharp
|
||||
<!-- Layouts/MainLayout.razor -->
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<header>@Header</header>
|
||||
<nav>@Navigation</nav>
|
||||
|
||||
<main>@Body</main>
|
||||
|
||||
<footer>@Footer</footer>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? Header { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Navigation { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Body { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Footer { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Apply Layout to Page
|
||||
|
||||
```csharp
|
||||
@page "/products"
|
||||
@layout MainLayout
|
||||
|
||||
<h2>Products</h2>
|
||||
```
|
||||
|
||||
### Apply Layout to Multiple Pages
|
||||
|
||||
```csharp
|
||||
<!-- _Imports.razor -->
|
||||
@layout MainLayout
|
||||
```
|
||||
|
||||
Add this line to `_Imports.razor` to apply layout to all components in folder and below.
|
||||
|
||||
### Nested Layouts
|
||||
|
||||
```csharp
|
||||
<!-- AdminLayout inherits from MainLayout -->
|
||||
@inherits MainLayout
|
||||
|
||||
<aside>Admin sidebar</aside>
|
||||
@Body
|
||||
```
|
||||
|
||||
## Page Titles
|
||||
|
||||
Update page title (browser tab) dynamically:
|
||||
|
||||
```csharp
|
||||
@page "/products/{id}"
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@title</PageTitle>
|
||||
|
||||
<h1>@title</h1>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? id { get; set; }
|
||||
|
||||
private string? title;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
title = await LoadProductTitleAsync(id);
|
||||
}
|
||||
|
||||
private async Task<string> LoadProductTitleAsync(string? id)
|
||||
{
|
||||
// Load from service
|
||||
return $"Product {id}";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Routing Patterns
|
||||
|
||||
### Master-Detail Pattern
|
||||
|
||||
```csharp
|
||||
@page "/products"
|
||||
@page "/products/{id}"
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr;">
|
||||
<ProductList OnSelectProduct="@SelectProduct" />
|
||||
@if (selectedId != null)
|
||||
{
|
||||
<ProductDetail Id="@selectedId" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? id { get; set; }
|
||||
|
||||
private string? selectedId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
selectedId = id;
|
||||
}
|
||||
|
||||
private void SelectProduct(string productId)
|
||||
{
|
||||
Navigation.NavigateTo($"/products/{productId}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Breadcrumb Navigation
|
||||
|
||||
```csharp
|
||||
@page "/category/{categoryId}/product/{productId}"
|
||||
|
||||
<div class="breadcrumb">
|
||||
<a href="/">Home</a> /
|
||||
<a href="/category/@categoryId">@categoryName</a> /
|
||||
<span>@productName</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? categoryId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? productId { get; set; }
|
||||
|
||||
private string? categoryName;
|
||||
private string? productName;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
categoryName = await LoadCategoryAsync(categoryId);
|
||||
productName = await LoadProductAsync(productId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tab-Based Navigation
|
||||
|
||||
```csharp
|
||||
@page "/settings"
|
||||
|
||||
<div class="tabs">
|
||||
<NavLink href="/settings/profile" Match="NavLinkMatch.All">Profile</NavLink>
|
||||
<NavLink href="/settings/security" Match="NavLinkMatch.All">Security</NavLink>
|
||||
<NavLink href="/settings/notifications" Match="NavLinkMatch.All">Notifications</NavLink>
|
||||
</div>
|
||||
|
||||
<Router AppAssembly="@typeof(App).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(SettingsLayout)" />
|
||||
</Found>
|
||||
</Router>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [components-lifecycle.md](components-lifecycle.md) for parameter handling. See [authentication-authorization.md](authentication-authorization.md) for route authorization.
|
||||
@@ -0,0 +1,575 @@
|
||||
# Blazor State Management & Events
|
||||
|
||||
## Component State
|
||||
|
||||
State represents the data that a component manages and renders.
|
||||
|
||||
### Local Component State
|
||||
|
||||
```csharp
|
||||
@page "/counter"
|
||||
|
||||
<p>Count: @count</p>
|
||||
<button @onclick="Increment">Click me</button>
|
||||
|
||||
@code {
|
||||
private int count = 0;
|
||||
|
||||
private void Increment()
|
||||
{
|
||||
count++;
|
||||
// Re-render happens automatically after event handler
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Blazor detects state change during event handler execution
|
||||
- Automatically calls `StateHasChanged()` after handler completes
|
||||
- Component re-renders with new state
|
||||
|
||||
### StateHasChanged() for External Updates
|
||||
|
||||
When state updates from outside Blazor's event system, call `StateHasChanged()` explicitly:
|
||||
|
||||
```csharp
|
||||
@implements IDisposable
|
||||
@inject IJSRuntime JS
|
||||
|
||||
private string? externalData;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Subscribe to external event
|
||||
JS.InvokeVoidAsync("subscribeToEvent", DotNetObjectReference.Create(this));
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void NotifyUpdate(string data)
|
||||
{
|
||||
externalData = data;
|
||||
// Blazor doesn't know about JS update, must call explicitly
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Clean up external subscriptions
|
||||
}
|
||||
```
|
||||
|
||||
### Thread-Safe State Updates with InvokeAsync()
|
||||
|
||||
When updating state from background threads (timers, async tasks outside event handlers):
|
||||
|
||||
```csharp
|
||||
@implements IDisposable
|
||||
|
||||
private Timer? timer;
|
||||
private int count = 0;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Timer running on background thread
|
||||
timer = new Timer(_ => UpdateCount(), null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
private void UpdateCount()
|
||||
{
|
||||
// WRONG - can't update state from background thread directly
|
||||
// count++;
|
||||
|
||||
// CORRECT - use InvokeAsync to marshal to UI thread
|
||||
InvokeAsync(() =>
|
||||
{
|
||||
count++;
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
timer?.Dispose();
|
||||
}
|
||||
```
|
||||
|
||||
### State Immutability Pattern
|
||||
|
||||
For complex state (objects, lists), follow immutability pattern:
|
||||
|
||||
```csharp
|
||||
@code {
|
||||
private List<Item> items = [];
|
||||
|
||||
// WRONG - mutates in place, may not trigger re-render
|
||||
private void AddItem()
|
||||
{
|
||||
items.Add(new Item { Name = "New" });
|
||||
}
|
||||
|
||||
// CORRECT - create new collection
|
||||
private void AddItem()
|
||||
{
|
||||
items = items.Append(new Item { Name = "New" }).ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
### Basic Click Handler
|
||||
|
||||
```csharp
|
||||
<button @onclick="HandleClick">Click me</button>
|
||||
|
||||
@code {
|
||||
private void HandleClick()
|
||||
{
|
||||
// Event handler logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### EventCallback Pattern (Recommended)
|
||||
|
||||
EventCallback is the proper way to notify parent components of events:
|
||||
|
||||
```csharp
|
||||
<!-- Child component -->
|
||||
<button @onclick="OnClick">Click me</button>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public EventCallback<string> OnValueChanged { get; set; }
|
||||
|
||||
private async Task OnClick()
|
||||
{
|
||||
await OnValueChanged.InvokeAsync("New Value");
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Parent component -->
|
||||
<ChildComponent OnValueChanged="@HandleValueChanged" />
|
||||
|
||||
@code {
|
||||
private void HandleValueChanged(string value)
|
||||
{
|
||||
// Handle value change
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### EventCallback with Arguments
|
||||
|
||||
```csharp
|
||||
<!-- Child -->
|
||||
<button @onclick="NotifyParent">Send Data</button>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public EventCallback<CustomArgs> OnDataChanged { get; set; }
|
||||
|
||||
private async Task NotifyParent()
|
||||
{
|
||||
var args = new CustomArgs { Id = 123, Name = "Test" };
|
||||
await OnValueChanged.InvokeAsync(args);
|
||||
}
|
||||
}
|
||||
|
||||
public class CustomArgs
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
<!-- Parent -->
|
||||
<ChildComponent OnDataChanged="@(args => HandleData(args.Id, args.Name))" />
|
||||
|
||||
@code {
|
||||
private void HandleData(int id, string? name)
|
||||
{
|
||||
// Process data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Event Handlers
|
||||
|
||||
Always use async properly with EventCallback:
|
||||
|
||||
```csharp
|
||||
<!-- Good - async handler, proper awaiting -->
|
||||
<button @onclick="SaveAsync">Save</button>
|
||||
|
||||
@code {
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
await Service.SaveDataAsync(data);
|
||||
successMessage = "Saved!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Event Handlers
|
||||
|
||||
```csharp
|
||||
<!-- Click -->
|
||||
<button @onclick="HandleClick">Click</button>
|
||||
|
||||
<!-- Double click -->
|
||||
<div @ondblclick="HandleDoubleClick">Double click</div>
|
||||
|
||||
<!-- Focus/Blur -->
|
||||
<input @onfocus="HandleFocus" @onblur="HandleBlur" />
|
||||
|
||||
<!-- Key events -->
|
||||
<input @onkeydown="HandleKeyDown" @onkeyup="HandleKeyUp" />
|
||||
|
||||
<!-- Mouse events -->
|
||||
<div @onmouseover="HandleMouseOver" @onmouseout="HandleMouseOut" />
|
||||
|
||||
<!-- Change -->
|
||||
<select @onchange="HandleChange">
|
||||
<option>Option 1</option>
|
||||
</select>
|
||||
|
||||
<!-- Submit -->
|
||||
<form @onsubmit="HandleSubmit">
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### preventDefault and stopPropagation
|
||||
|
||||
```csharp
|
||||
<!-- Prevent form submission -->
|
||||
<form @onsubmit:preventDefault="true" @onsubmit="HandleSubmit">
|
||||
<input type="text" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
<!-- Stop event propagation -->
|
||||
<div @onclick="ParentClick">
|
||||
<button @onclick="ChildClick" @onclick:stopPropagation="true">
|
||||
Click - won't bubble
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Data Binding
|
||||
|
||||
### Two-Way Binding (@bind)
|
||||
|
||||
```csharp
|
||||
<input @bind="name" />
|
||||
<p>You entered: @name</p>
|
||||
|
||||
@code {
|
||||
private string name = "";
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- `@bind` = `@bind-value` + `@bind-value:event="onchange"`
|
||||
- Sets value property, listens to onchange event
|
||||
- Automatic two-way synchronization
|
||||
|
||||
### Custom Events with @bind
|
||||
|
||||
```csharp
|
||||
<input @bind="value" @bind:event="oninput" />
|
||||
|
||||
@code {
|
||||
private string value = "";
|
||||
}
|
||||
```
|
||||
|
||||
Events: `onchange` (default), `oninput` (real-time), `onblur`, etc.
|
||||
|
||||
### Numeric Binding
|
||||
|
||||
```csharp
|
||||
<input @bind="age" @bind:culture="CultureInfo.InvariantCulture" />
|
||||
|
||||
@code {
|
||||
private int age = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### DateTime Binding
|
||||
|
||||
```csharp
|
||||
<input type="date" @bind="date" />
|
||||
<input type="datetime-local" @bind="dateTime" />
|
||||
|
||||
@code {
|
||||
private DateOnly date = DateOnly.FromDateTime(DateTime.Now);
|
||||
private DateTime dateTime = DateTime.Now;
|
||||
}
|
||||
```
|
||||
|
||||
### Binding with Format Specifiers
|
||||
|
||||
```csharp
|
||||
<input @bind="price" @bind:format="N2" />
|
||||
<p>Price: @price.ToString("C")</p>
|
||||
|
||||
@code {
|
||||
private decimal price = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### Bind Modifiers
|
||||
|
||||
```csharp
|
||||
<!-- @bind:get / @bind:set for custom logic -->
|
||||
<input @bind="@value"
|
||||
@bind:get="parsedValue"
|
||||
@bind:set="@SetValue" />
|
||||
|
||||
@code {
|
||||
private string value = "";
|
||||
|
||||
private string parsedValue
|
||||
{
|
||||
get => value.ToUpper();
|
||||
}
|
||||
|
||||
private void SetValue(string val)
|
||||
{
|
||||
value = val.ToLower();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cascading Values with Events
|
||||
|
||||
Provide shared state and event callbacks to child components:
|
||||
|
||||
```csharp
|
||||
<!-- Parent - AppState provider -->
|
||||
<CascadingValue Value="@appState">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
|
||||
@code {
|
||||
private AppState appState = new();
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
|
||||
<!-- AppState service -->
|
||||
public class AppState
|
||||
{
|
||||
private string _username = "";
|
||||
public event Action? OnChange;
|
||||
|
||||
public string Username
|
||||
{
|
||||
get => _username;
|
||||
set
|
||||
{
|
||||
if (_username != value)
|
||||
{
|
||||
_username = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyStateChanged() => OnChange?.Invoke();
|
||||
}
|
||||
|
||||
<!-- Child component - subscribe to state changes -->
|
||||
@implements IDisposable
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public AppState? AppState { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (AppState != null)
|
||||
{
|
||||
AppState.OnChange += StateHasChanged;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (AppState != null)
|
||||
{
|
||||
AppState.OnChange -= StateHasChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Service-Based State Management
|
||||
|
||||
For application-wide state, use services:
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
builder.Services.AddScoped<AppState>();
|
||||
|
||||
// AppState service
|
||||
public class AppState
|
||||
{
|
||||
private string _theme = "light";
|
||||
private User? _currentUser;
|
||||
|
||||
public event Func<Task>? OnStateChange;
|
||||
|
||||
public string Theme
|
||||
{
|
||||
get => _theme;
|
||||
set
|
||||
{
|
||||
if (_theme != value)
|
||||
{
|
||||
_theme = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public User? CurrentUser
|
||||
{
|
||||
get => _currentUser;
|
||||
set
|
||||
{
|
||||
if (_currentUser != value)
|
||||
{
|
||||
_currentUser = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyStateChanged()
|
||||
{
|
||||
if (OnStateChange != null)
|
||||
{
|
||||
await OnStateChange.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Component using AppState
|
||||
@inject AppState AppState
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AppState.OnStateChange += StateHasChanged;
|
||||
AppState.CurrentUser = await LoadUserAsync();
|
||||
}
|
||||
|
||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
||||
{
|
||||
if (AppState != null)
|
||||
{
|
||||
AppState.OnStateChange -= StateHasChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Parent-Child Communication Pattern
|
||||
|
||||
**Data flow:** Parents pass data DOWN via parameters, children notify UP via events.
|
||||
|
||||
```csharp
|
||||
<!-- Parent -->
|
||||
@page "/parent"
|
||||
|
||||
<h2>Parent: @selectedId</h2>
|
||||
<Child SelectedId="@selectedId"
|
||||
OnIdChanged="@HandleIdChanged" />
|
||||
|
||||
@code {
|
||||
private int selectedId = 0;
|
||||
|
||||
private async Task HandleIdChanged(int newId)
|
||||
{
|
||||
selectedId = newId;
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Child -->
|
||||
<select @onchange="OnSelectionChanged">
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
<option value="@item.Id">@item.Name</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int SelectedId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> OnIdChanged { get; set; }
|
||||
|
||||
private List<Item> Items { get; set; } = [];
|
||||
|
||||
private async Task OnSelectionChanged(ChangeEventArgs args)
|
||||
{
|
||||
var newId = int.Parse(args.Value?.ToString() ?? "0");
|
||||
await OnIdChanged.InvokeAsync(newId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Always Use EventCallback
|
||||
- ✅ `[Parameter] public EventCallback OnEvent { get; set; }`
|
||||
- ❌ `[Parameter] public Action? OnEvent { get; set; }`
|
||||
|
||||
EventCallback handles async properly and integrates better with Blazor's rendering pipeline.
|
||||
|
||||
### Keep Event Handlers Focused
|
||||
- Do one thing per handler
|
||||
- Move complex logic to services
|
||||
- Keep components as thin view layers
|
||||
|
||||
### Unsubscribe from Events
|
||||
Always clean up subscriptions to prevent memory leaks:
|
||||
|
||||
```csharp
|
||||
@implements IDisposable
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Service.OnChange += HandleChange;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Service.OnChange -= HandleChange;
|
||||
}
|
||||
```
|
||||
|
||||
### Use Immutable Updates
|
||||
- Create new objects/collections for state updates
|
||||
- Don't mutate objects in place
|
||||
- Helps with change detection and debugging
|
||||
|
||||
---
|
||||
|
||||
**Related Resources:** See [components-lifecycle.md](components-lifecycle.md) for component parameters and cascading values. See [forms-validation.md](forms-validation.md) for form event handling.
|
||||
Reference in New Issue
Block a user