Table of Contents
- State Management Fundamentals
- Local State at the Component Level
- Sharing State Between Components
- State Management with Services and DI
- Blazor WASM and Auto Mode
- State Persistence
- Enterprise Applications — Advanced Scenarios
- Best Practices
- Practical Exercises
- Glossary
1. State Management Fundamentals
1.1 What is State?
Definition: State is the set of current data that defines the behavior and appearance of an application at any given moment.
flowchart LR
subgraph State["Examples of state in an app"]
S1["User preferences\n(theme, language, font size)"]
S2["Form data\n(current input)"]
S3["E-commerce cart\n(items added)"]
S4["Authentication\n(logged-in user)"]
S5["Navigation\n(current page, history)"]
S6["Data cache\n(API query results)"]
end
App[Blazor\nApplication] --> State
Why does state management matter?
| Without proper state management | With proper state management |
|---|---|
| Inconsistent data between components | Single source of truth |
| State lost during navigation | Persistent and consistent state |
| Hard-to-debug code | Predictable data flow |
| Hard-to-reuse components | Independent and testable components |
| Unpredictable behavior | Deterministic behavior |
1.2 Blazor-Specific Challenges
flowchart TB
subgraph BlazorServer["Blazor Server"]
BSC[SignalR Circuit\nper user]
BSS[State on\nthe server]
BSC --> BSS
note1["✅ State easily shared\nbetween components\n⚠️ Limited scalability\n(server memory per user)"]
end
subgraph BlazorWASM["Blazor WASM"]
WC[Browser]
WS[State in\nthe browser]
WC --> WS
note2["✅ No server load\n⚠️ State lost on refresh\n⚠️ Client/server sync"]
end
subgraph BlazorAuto["Blazor Auto (hybrid mode)"]
AC[Server on\nfirst render]
AW[WASM after\ncaching]
AC --> AW
note3["⚠️ State transition\nServer → WASM required\n✅ Better UX"]
end
1.3 The Blazing Blog Application
The course demo application is a simplified blogging platform:
Blazing Blog — Wireframe
┌─────────────────────────────────────────────────┐
│ [+ New Post] │
│ ───────────────────────────────────────────── │
│ Post list │ Post details │
│ ───────────────── │ ───────────────── │
│ ● Post title 1 [click]│ Title : Post 1 │
│ ● Post title 2 │ Full content... │
│ ● Post title 3 │ │
│ [+ Add] │ [Edit] │
└─────────────────────────────────────────────────┘
2. Local State at the Component Level
2.1 Basic Patterns
@* Simple counter — local component state *@
@rendermode InteractiveServer
<div class="counter-component">
<p>Value: @_count</p>
<div class="btn-group">
<button class="btn btn-outline-secondary" @onclick="Decrement">-</button>
<button class="btn btn-primary" @onclick="Increment">+</button>
<button class="btn btn-outline-danger" @onclick="Reset">Reset</button>
</div>
</div>
@code {
private int _count = 0;
private void Increment() => _count++;
private void Decrement() => _count > 0 ? _count-- : _count;
private void Reset() => _count = 0;
}
Show/hide an element:
@rendermode InteractiveServer
<button class="btn btn-info" @onclick="ToggleDetails">
@(_showDetails ? "Hide" : "View details")
</button>
@if (_showDetails)
{
<div class="details-panel animate__animated animate__fadeIn">
<h4>Details</h4>
<p>Conditionally displayed content</p>
</div>
}
@code {
private bool _showDetails = false;
private void ToggleDetails() => _showDetails = !_showDetails;
}
Dynamic list:
@rendermode InteractiveServer
<ul class="list-group mb-3">
@foreach (var item in _items)
{
<li class="list-group-item d-flex justify-content-between align-items-center">
@item
<button class="btn btn-sm btn-outline-danger"
@onclick="@(() => RemoveItem(item))">×</button>
</li>
}
</ul>
<div class="input-group">
<input type="text" class="form-control" @bind="_newItem"
@bind:event="oninput" placeholder="New item..." />
<button class="btn btn-success" @onclick="AddItem"
disabled="@string.IsNullOrWhiteSpace(_newItem)">
Add
</button>
</div>
@code {
private List<string> _items = new() { "Item 1", "Item 2" };
private string _newItem = string.Empty;
private void AddItem()
{
if (!string.IsNullOrWhiteSpace(_newItem))
{
_items.Add(_newItem.Trim());
_newItem = string.Empty;
}
}
private void RemoveItem(string item) => _items.Remove(item);
}
2.2 User Stories — Blazing Blog Application
User Story 1 — Post list:
GIVEN I am on the home page
WHEN I look at the left panel
THEN I see a list of blog titles
AND each title is clickable
@* Components/BlogList.razor *@
@rendermode InteractiveServer
<div class="blog-list-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5>Posts</h5>
<button class="btn btn-sm btn-success" @onclick="AddPost">+ New</button>
</div>
<ul class="list-group">
@foreach (var post in _posts)
{
<li class="list-group-item list-group-item-action @(post == _selectedPost ? "active" : "")"
@onclick="@(() => SelectPost(post))"
style="cursor: pointer;">
@post.Title
</li>
}
</ul>
</div>
@code {
private List<BlogPost> _posts = new();
private BlogPost? _selectedPost;
protected override void OnInitialized()
{
// Initial data (will be replaced by a service)
_posts = new List<BlogPost>
{
new() { Id = 1, Title = "My first post", Content = "Content..." },
new() { Id = 2, Title = "Blazor is awesome", Content = "Yes, really!" },
new() { Id = 3, Title = "State Management", Content = "An important topic" }
};
}
private void SelectPost(BlogPost post) => _selectedPost = post;
private void AddPost()
{
var newPost = new BlogPost
{
Id = _posts.Count + 1,
Title = "New post",
Content = ""
};
_posts.Add(newPost);
_selectedPost = newPost;
}
}
3. Sharing State Between Components
3.1 Communication Patterns
flowchart TD
subgraph Patterns["State sharing patterns"]
P1["1. Parameters\n(Parent → Child)"]
P2["2. EventCallback\n(Child → Parent)"]
P3["3. CascadingParameter\n(Ancestor → Descendant)"]
P4["4. DI Service\n(All ↔ All)"]
P5["5. URL/Query\n(Navigation)"]
end
P1 --> U1["Simple data passing\ndown the hierarchy"]
P2 --> U2["Event notification\nto the parent"]
P3 --> U3["Theme, auth state\nLarge hierarchies"]
P4 --> U4["Unrelated components\nComplex state"]
P5 --> U5["Bookmarkable state\nDirect navigation"]
3.2 CascadingParameter — For Widely Shared Values
// Program.cs — Make state available throughout the application
var builder = WebApplication.CreateBuilder(args);
// App state accessible via CascadingParameter in all components
var blogState = new BlogAppState();
builder.Services.AddSingleton(blogState);
// Or: builder.Services.AddScoped for per-user state
// For CascadingValue, pass the value in App.razor or Routes.razor:
// <CascadingValue Value="@blogState">
// <Routes />
// </CascadingValue>
@* Routes.razor — Expose as cascading parameter *@
<CascadingValue Value="@_blogState">
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
</Found>
</Router>
</CascadingValue>
@code {
// State is accessible to all descendant components
private BlogAppState _blogState = new();
}
Consuming the CascadingParameter:
@* In any descendant component *@
@code {
// Automatically receives the cascading value
[CascadingParameter]
private BlogAppState? BlogState { get; set; }
protected override void OnInitialized()
{
if (BlogState is not null)
{
// Access posts via global state
_posts = BlogState.GetAllPosts().ToList();
}
}
}
Issues with CascadingParameters:
- Do not use for large amounts of data
- Tight coupling to the component hierarchy
- Hard to reuse outside the context
- Prefer DI services for application state
3.3 Sibling Components — The Problem
App
/ \
BlogList PostDetail
BlogList and PostDetail are sibling components. They cannot directly pass data to each other. The solution: lift state to the common parent (App) or use a shared service.
4. State Management with Services and DI
4.1 BlogRepository — Centralized Service
// Contracts/IBlogRepository.cs
public interface IBlogRepository
{
IEnumerable<BlogPost> GetAllPosts();
BlogPost? GetPostById(int id);
BlogPost AddPost(string title, string content);
BlogPost UpdatePost(int id, string title, string content);
void DeletePost(int id);
}
// Data/BlogRepository.cs
public class BlogRepository : IBlogRepository
{
private readonly List<BlogPost> _posts;
private int _nextId;
public BlogRepository()
{
// Initial data
_posts = new List<BlogPost>
{
new() { Id = 1, Title = "My first Blazor post",
Content = "Blazor is a fantastic framework..." },
new() { Id = 2, Title = "State Management in Blazor",
Content = "Managing state is crucial for applications..." },
new() { Id = 3, Title = "WASM vs Server",
Content = "Each mode has its advantages..." }
};
_nextId = _posts.Count + 1;
}
public IEnumerable<BlogPost> GetAllPosts() => _posts.OrderByDescending(p => p.Id);
public BlogPost? GetPostById(int id) => _posts.FirstOrDefault(p => p.Id == id);
public BlogPost AddPost(string title, string content)
{
var post = new BlogPost
{
Id = _nextId++,
Title = title,
Content = content,
CreatedAt = DateTime.UtcNow
};
_posts.Add(post);
return post;
}
public BlogPost UpdatePost(int id, string title, string content)
{
var post = GetPostById(id)
?? throw new KeyNotFoundException($"Post {id} not found");
post.Title = title;
post.Content = content;
post.UpdatedAt = DateTime.UtcNow;
return post;
}
public void DeletePost(int id)
{
var post = GetPostById(id);
if (post is not null) _posts.Remove(post);
}
}
Registration:
// Program.cs
// Scoped = one instance per SignalR circuit (per connected user)
builder.Services.AddScoped<IBlogRepository, BlogRepository>();
// Singleton = one instance for the entire application (all users)
// ⚠️ Watch out for concurrency issues with Singleton in Blazor Server
// builder.Services.AddSingleton<IBlogRepository, BlogRepository>();
4.2 Refactored Components with the Service
@* Components/BlogList.razor — With injected service *@
@rendermode InteractiveServer
@inject IBlogRepository Repository
@inject IBlogRepository BlogRepo
<div class="blog-list-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5>Posts (@_posts.Count())</h5>
<button class="btn btn-sm btn-success" @onclick="AddNewPost">
+ New
</button>
</div>
@if (!_posts.Any())
{
<p class="text-muted fst-italic">No posts yet. Create one!</p>
}
else
{
<ul class="list-group">
@foreach (var post in _posts)
{
<li class="list-group-item list-group-item-action
@(post.Id == _selectedPostId ? "active" : "")"
@onclick="@(() => SelectPost(post))"
role="button">
<div class="d-flex justify-content-between">
<span>@post.Title</span>
<button class="btn btn-sm btn-link text-danger p-0"
@onclick:stopPropagation="true"
@onclick="@(() => DeletePost(post.Id))">
×
</button>
</div>
@if (post.UpdatedAt is not null)
{
<small class="text-muted">
Updated on @post.UpdatedAt.Value.ToString("MM/dd/yyyy")
</small>
}
</li>
}
</ul>
}
</div>
@code {
[Parameter]
public EventCallback<BlogPost> OnPostSelected { get; set; }
private IEnumerable<BlogPost> _posts = Enumerable.Empty<BlogPost>();
private int? _selectedPostId;
protected override void OnInitialized()
{
LoadPosts();
}
private void LoadPosts()
{
_posts = Repository.GetAllPosts();
}
private async Task SelectPost(BlogPost post)
{
_selectedPostId = post.Id;
await OnPostSelected.InvokeAsync(post);
}
private void AddNewPost()
{
var post = Repository.AddPost("New post", "");
LoadPosts();
_ = SelectPost(post);
}
private void DeletePost(int id)
{
Repository.DeletePost(id);
if (_selectedPostId == id)
{
_selectedPostId = null;
_ = OnPostSelected.InvokeAsync(null!);
}
LoadPosts();
}
}
5. Blazor WASM and Auto Mode
5.1 Server + Client Architecture
flowchart TB
subgraph Server["Blazor Server Project"]
SPages[SSR Pages\nBlogList, Home]
SRepo[BlogRepository\nIn-memory data]
SAPI[Minimal API\n/api/posts]
SPages --> SRepo
SAPI --> SRepo
end
subgraph Client["Blazor WASM Client Project"]
CPages[WASM Pages\nEditPost]
CService[ClientBlogService\nHttpClient → API]
CPages --> CService
end
Browser[Browser] -->|"1st visit: SignalR"| Server
Browser -->|"Subsequent visits: WASM"| Client
CService -->|"GET/PUT /api/posts"| SAPI
style Server fill:#E4F3FF
style Client fill:#FFE4B5
5.2 Minimal API for Data
// Program.cs — API for the WASM client
app.MapGroup("/api/posts")
.MapPost_Endpoints();
// Extension method to organize endpoints
public static class PostEndpoints
{
public static RouteGroupBuilder MapPost_Endpoints(this RouteGroupBuilder group)
{
group.MapGet("/", (IBlogRepository repo) =>
Results.Ok(repo.GetAllPosts()));
group.MapGet("/{id:int}", (int id, IBlogRepository repo) =>
{
var post = repo.GetPostById(id);
return post is null ? Results.NotFound() : Results.Ok(post);
});
group.MapPost("/", (CreatePostRequest request, IBlogRepository repo) =>
{
var post = repo.AddPost(request.Title, request.Content);
return Results.Created($"/api/posts/{post.Id}", post);
});
group.MapPut("/{id:int}", (int id, UpdatePostRequest request, IBlogRepository repo) =>
{
try
{
var post = repo.UpdatePost(id, request.Title, request.Content);
return Results.Ok(post);
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
});
group.MapDelete("/{id:int}", (int id, IBlogRepository repo) =>
{
repo.DeletePost(id);
return Results.NoContent();
});
return group;
}
}
public record CreatePostRequest(string Title, string Content);
public record UpdatePostRequest(string Title, string Content);
5.3 Edit Page in WASM Mode
@* BlazingBlog.Client/Components/Pages/EditPost.razor *@
@page "/posts/{PostId:int}/edit"
@rendermode InteractiveAuto @* ← Auto mode: Server then WASM *@
@inject IClientBlogService BlogService
@inject NavigationManager NavManager
<PageTitle>Edit post</PageTitle>
@if (_isLoading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2">Loading...</p>
</div>
}
else if (_post is null)
{
<div class="alert alert-danger">Post not found.</div>
}
else
{
@* Proof that this code runs in the browser *@
@if (OperatingSystem.IsBrowser())
{
<div class="alert alert-info">WASM Mode — C# code running in the browser!</div>
}
<div class="edit-form">
<h2>Edit: @_originalTitle</h2>
@if (_hasLocalChanges)
{
<div class="alert alert-warning">
<strong>Unsaved changes</strong> —
Your changes are preserved locally.
</div>
}
<div class="mb-3">
<label class="form-label">Title</label>
<input type="text" class="form-control"
@bind="_post.Title" @bind:event="oninput"
@oninput="OnTitleChanged" />
</div>
<div class="mb-3">
<label class="form-label">Content</label>
<textarea class="form-control" rows="10"
@bind="_post.Content" @bind:event="oninput"
@oninput="OnContentChanged"></textarea>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" @onclick="SavePost" disabled="@_isSaving">
@(_isSaving ? "Saving..." : "Save")
</button>
<button class="btn btn-outline-secondary" @onclick="Cancel">
Cancel
</button>
</div>
</div>
}
// EditPost.razor.cs
public partial class EditPost
{
[Parameter] public int PostId { get; set; }
[Inject] private IClientBlogService BlogService { get; set; } = default!;
[Inject] private NavigationManager NavManager { get; set; } = default!;
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
private BlogPost? _post;
private string _originalTitle = string.Empty;
private bool _isLoading = true;
private bool _isSaving;
private bool _hasLocalChanges;
private const string StorageKeyPrefix = "post_";
protected override async Task OnInitializedAsync()
{
_post = await BlogService.GetPostByIdAsync(PostId);
if (_post is not null)
{
_originalTitle = _post.Title;
// Load unsaved local changes
await LoadLocalChangesAsync();
}
_isLoading = false;
}
private async Task LoadLocalChangesAsync()
{
var cached = await JSRuntime.InvokeAsync<string?>(
"localStorage.getItem", $"{StorageKeyPrefix}{PostId}");
if (cached is not null)
{
var cachedPost = JsonSerializer.Deserialize<BlogPost>(cached);
if (cachedPost is not null && _post is not null)
{
_post.Title = cachedPost.Title;
_post.Content = cachedPost.Content;
_hasLocalChanges = true;
}
}
}
private async Task OnTitleChanged(ChangeEventArgs e)
{
await SaveLocalChangesAsync();
}
private async Task OnContentChanged(ChangeEventArgs e)
{
await SaveLocalChangesAsync();
}
private async Task SaveLocalChangesAsync()
{
if (_post is null) return;
// Save to localStorage
var json = JsonSerializer.Serialize(new { _post.Title, _post.Content });
await JSRuntime.InvokeVoidAsync("localStorage.setItem",
$"{StorageKeyPrefix}{PostId}", json);
_hasLocalChanges = true;
}
private async Task SavePost()
{
if (_post is null) return;
_isSaving = true;
try
{
await BlogService.UpdatePostAsync(_post);
// Clear local cache
await JSRuntime.InvokeVoidAsync("localStorage.removeItem",
$"{StorageKeyPrefix}{PostId}");
_hasLocalChanges = false;
NavManager.NavigateTo("/");
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("alert", $"Error: {ex.Message}");
}
finally
{
_isSaving = false;
}
}
private void Cancel()
{
NavManager.NavigateTo("/");
}
}
6. State Persistence
6.1 Server-Side Persistence — SQLite + EF Core
// Data/BlogDbContext.cs
public class BlogDbContext : DbContext
{
public BlogDbContext(DbContextOptions<BlogDbContext> options) : base(options) { }
public DbSet<BlogPost> BlogPosts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<BlogPost>().HasData(
new BlogPost { Id = 1, Title = "Welcome to Blazing Blog",
Content = "First post...", CreatedAt = DateTime.UtcNow }
);
}
}
// Program.cs — SQLite configuration
builder.Services.AddDbContext<BlogDbContext>(opts =>
opts.UseSqlite(builder.Configuration.GetConnectionString("Default")
?? "Data Source=blazingblog.db"));
// Migration:
// dotnet ef migrations add InitialBlogMigration
// dotnet ef database update
Repository with persistence:
// Data/PersistentBlogRepository.cs
public class PersistentBlogRepository : IBlogRepository
{
// IMPORTANT: Use IDbContextFactory for Blazor Server
// (DbContext is not thread-safe)
private readonly IDbContextFactory<BlogDbContext> _contextFactory;
public PersistentBlogRepository(IDbContextFactory<BlogDbContext> contextFactory)
=> _contextFactory = contextFactory;
public IEnumerable<BlogPost> GetAllPosts()
{
using var ctx = _contextFactory.CreateDbContext();
return ctx.BlogPosts
.OrderByDescending(p => p.CreatedAt)
.AsNoTracking()
.ToList();
}
public BlogPost? GetPostById(int id)
{
using var ctx = _contextFactory.CreateDbContext();
return ctx.BlogPosts.AsNoTracking().FirstOrDefault(p => p.Id == id);
}
public BlogPost AddPost(string title, string content)
{
using var ctx = _contextFactory.CreateDbContext();
var post = new BlogPost
{
Title = title,
Content = content,
CreatedAt = DateTime.UtcNow
};
ctx.BlogPosts.Add(post);
ctx.SaveChanges();
return post;
}
public BlogPost UpdatePost(int id, string title, string content)
{
using var ctx = _contextFactory.CreateDbContext();
var post = ctx.BlogPosts.Find(id)
?? throw new KeyNotFoundException($"Post {id} not found.");
post.Title = title;
post.Content = content;
post.UpdatedAt = DateTime.UtcNow;
ctx.SaveChanges();
return post;
}
public void DeletePost(int id)
{
using var ctx = _contextFactory.CreateDbContext();
var post = ctx.BlogPosts.Find(id);
if (post is not null)
{
ctx.BlogPosts.Remove(post);
ctx.SaveChanges();
}
}
}
// Program.cs — Use the persistent version
// builder.Services.AddScoped<IBlogRepository, BlogRepository>(); // In-memory
builder.Services.AddScoped<IBlogRepository, PersistentBlogRepository>(); // SQLite
builder.Services.AddDbContextFactory<BlogDbContext>(...);
⚠️ Common pitfall — DbContext and Blazor Server: Never inject
DbContextdirectly into a Blazor Server component.DbContextis not thread-safe and Blazor Server can execute multiple operations in parallel. UseIDbContextFactory<T>instead.
6.2 Client-Side Persistence — localStorage
// Save unsubmitted changes in localStorage
// Useful when the user navigates away without saving
private async Task CacheLocalChangesAsync(BlogPost post)
{
var data = new
{
post.Title,
post.Content,
CachedAt = DateTime.UtcNow
};
await JSRuntime.InvokeVoidAsync(
"localStorage.setItem",
$"draft_post_{post.Id}",
JsonSerializer.Serialize(data));
}
private async Task<(string? Title, string? Content)?> LoadCachedChangesAsync(int postId)
{
var json = await JSRuntime.InvokeAsync<string?>(
"localStorage.getItem", $"draft_post_{postId}");
if (json is null) return null;
var cached = JsonSerializer.Deserialize<dynamic>(json);
return (cached?.Title?.ToString(), cached?.Content?.ToString());
}
private async Task ClearCacheAsync(int postId)
=> await JSRuntime.InvokeVoidAsync(
"localStorage.removeItem", $"draft_post_{postId}");
Comparison of persistence options:
| Mechanism | Scope | Duration | Security | Max Size |
|---|---|---|---|---|
| Server memory (Scoped) | Per circuit | Session | ✅ Good | Unlimited (server) |
| Database | Global | Permanent | ✅ Excellent | Unlimited |
| localStorage | Browser | Permanent | ⚠️ Visible in plaintext | ~5-10 MB |
| sessionStorage | Tab | Tab close | ⚠️ Visible in plaintext | ~5-10 MB |
| Cookies | Domain | Configurable | ⚠️ Sent to server | ~4 KB |
| IndexedDB | Browser | Permanent | ⚠️ Visible | ~1 GB+ |
7. Enterprise Applications — Advanced Scenarios
7.1 Multi-User Coordination
In enterprise applications, multiple users can modify the same data simultaneously. Strategies to handle this:
flowchart TB
subgraph Optimistic["Optimistic Concurrency\n(Recommended for frequent reads)"]
O1[User A reads post v1]
O2[User B reads post v1]
O3[User A edits → v2]
O4[User B attempts to edit v1]
O5[Conflict detected!\nVersion mismatch]
O1 --> O3
O2 --> O4
O4 --> O5
end
subgraph Pessimistic["Pessimistic Concurrency\n(For frequent writes)"]
P1[User A acquires a lock]
P2[User B waits]
P3[User A finishes, releases]
P4[User B can now edit]
P1 --> P2
P2 --> P3
P3 --> P4
end
Conflict handling with RowVersion:
// BlogPost.cs — Version for optimistic concurrency
public class BlogPost
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
// Version for optimistic concurrency
[Timestamp]
public byte[]? RowVersion { get; set; }
}
// In the repository
public BlogPost UpdatePost(int id, string title, string content, byte[]? rowVersion)
{
using var ctx = _contextFactory.CreateDbContext();
var post = ctx.BlogPosts.Find(id) ?? throw new KeyNotFoundException();
// Verify the version has not changed
if (rowVersion is not null && !post.RowVersion.SequenceEqual(rowVersion))
throw new DbUpdateConcurrencyException("This post was modified by another user.");
post.Title = title;
post.Content = content;
post.UpdatedAt = DateTime.UtcNow;
try
{
ctx.SaveChanges();
return post;
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException("Version conflict. Reload the post.");
}
}
7.2 Security and Compliance
// Never store sensitive data in localStorage!
// ❌ BAD — Sensitive data in plaintext
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "creditCard", "4111111111111111");
// ✅ GOOD — Sensitive data server-side only
// Store only non-sensitive data on the client
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "userPreferences",
JsonSerializer.Serialize(new { Theme = "dark", Language = "en" }));
// For personal data (GDPR):
// - Minimize data collection
// - Encrypt sensitive data
// - Allow deletion
// - Audit logs for access
7.3 State Testing
// Repository tests with bUnit or xUnit
public class BlogRepositoryTests
{
[Fact]
public void AddPost_ShouldAddToList()
{
// Arrange
var repo = new BlogRepository(); // In-memory
var initialCount = repo.GetAllPosts().Count();
// Act
var post = repo.AddPost("Test", "Content");
// Assert
Assert.Equal(initialCount + 1, repo.GetAllPosts().Count());
Assert.Equal("Test", post.Title);
}
[Fact]
public void UpdatePost_ShouldUpdateExisting()
{
// Arrange
var repo = new BlogRepository();
var post = repo.AddPost("Original", "Content");
// Act
var updated = repo.UpdatePost(post.Id, "Modified", "New content");
// Assert
Assert.Equal("Modified", updated.Title);
Assert.NotNull(updated.UpdatedAt);
}
[Fact]
public void UpdatePost_WithInvalidId_ShouldThrow()
{
// Arrange
var repo = new BlogRepository();
// Act & Assert
Assert.Throws<KeyNotFoundException>(() =>
repo.UpdatePost(999, "Title", "Content"));
}
}
7.4 Performance — Optimizations
// 1. Lazy loading — Only load what is necessary
public async Task<IEnumerable<BlogPostSummary>> GetPostSummariesAsync(
int page, int pageSize)
{
await using var ctx = await _contextFactory.CreateDbContextAsync();
return await ctx.BlogPosts
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new BlogPostSummary
{
Id = p.Id,
Title = p.Title,
CreatedAt = p.CreatedAt,
// Do not load the full content in the summary!
Summary = p.Content.Substring(0, Math.Min(200, p.Content.Length)) + "..."
})
.AsNoTracking()
.ToListAsync();
}
// 2. In-memory caching for frequently read data
public class CachedBlogRepository : IBlogRepository
{
private readonly PersistentBlogRepository _inner;
private readonly IMemoryCache _cache;
public CachedBlogRepository(PersistentBlogRepository inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public IEnumerable<BlogPost> GetAllPosts()
{
return _cache.GetOrCreate("all_posts", entry =>
{
entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(5));
return _inner.GetAllPosts();
}) ?? Enumerable.Empty<BlogPost>();
}
public BlogPost AddPost(string title, string content)
{
var post = _inner.AddPost(title, content);
_cache.Remove("all_posts"); // Invalidate cache
return post;
}
}
8. Best Practices
8.1 Golden Rules
flowchart TD
subgraph Rules["Golden rules for state management"]
R1["1. Single source of truth\n→ One place for each piece of data"]
R2["2. Immutability\n→ Replace, don't mutate in place"]
R3["3. Unidirectionality\n→ Data always flows from parent to child"]
R4["4. Appropriate scope\n→ Scoped > Singleton for user data"]
R5["5. Proper Dispose\n→ Unsubscribe from events in Dispose()"]
R6["6. No sensitive data on the client\n→ Server-side only"]
end
8.2 Choosing the Right State Mechanism
| Scenario | Recommended mechanism |
|---|---|
| Single component state (counter) | Local @code variables |
| Data passed from parent to child | [Parameter] |
| Notification from child to parent | EventCallback |
| Theme or app config | CascadingParameter or Singleton service |
| Shared business data | Scoped service + DI |
| Persistence between sessions | Database (server) or localStorage (client) |
| Temporary unsaved drafts | localStorage |
| Real-time multi-user | SignalR + shared service |
8.3 Anti-Patterns to Avoid
@* ❌ BAD — Mutating a received parameter *@
@code {
[Parameter]
public BlogPost Post { get; set; } = default!; // ← Not good
private void UpdateTitle(string title)
{
Post.Title = title; // ❌ Never mutate a parameter directly!
// Use EventCallback instead
}
}
@* ✅ GOOD — Notify the parent *@
@code {
[Parameter]
public BlogPost Post { get; set; } = default!;
[Parameter]
public EventCallback<string> OnTitleChanged { get; set; }
private async Task UpdateTitle(string title)
{
await OnTitleChanged.InvokeAsync(title); // ✅ Notify the parent
}
}
// ❌ BAD — Forgetting to unsubscribe
public partial class MyComponent
{
protected override void OnInitialized()
{
AppState.OnChange += StateHasChanged; // ← Memory leak if no Dispose
}
}
// ✅ GOOD — Implement IDisposable
public partial class MyComponent : IDisposable
{
protected override void OnInitialized()
{
AppState.OnChange += StateHasChanged;
}
public void Dispose()
{
AppState.OnChange -= StateHasChanged; // ← Always unsubscribe!
}
}
9. Practical Exercises
Exercise 1 — List with Local State (Beginner, ~1h)
Goal: Create a Todo List with local state.
Features:
- Add a task
- Check/uncheck a task
- Delete a task
- Filter by status (all / in progress / completed)
- Remaining tasks counter
Exercise 2 — State Service (Intermediate, ~2h)
Goal: Share state between BlogList and PostDetail via a service.
Steps:
- Create
IBlogRepositoryandBlogRepository - Register with
AddScoped<IBlogRepository, BlogRepository>() - Inject into
BlogListandPostDetail BlogList→ selects a post →PostDetaildisplays the details
Exercise 3 — localStorage Persistence (Advanced, ~2h)
Goal: Save unsubmitted drafts locally.
Steps:
- Create the
EditPostpage with[rendermode InteractiveAuto] - On every change, save to
localStorage - On load, check if a draft exists
- If yes, offer to restore it
- On save, clear the draft
Exercise 4 — SQLite Persistence (Advanced, ~2h)
Goal: Replace the in-memory repository with SQLite.
Steps:
- Create
BlogDbContextwithDbSet<BlogPost> - Add migrations:
Add-Migration Initial,Update-Database - Implement
PersistentBlogRepository - Use
IDbContextFactory(notDbContextdirectly) - Verify data persists after restart
10. Glossary
| Term | Definition |
|---|---|
| ABAC | Attribute-Based Access Control — access control based on attributes |
| ApplicationState | C# service holding the application’s shared state |
| Blazor Server | Blazor mode running code on the server via SignalR |
| Blazor WASM | Blazor mode running code in the browser via WebAssembly |
| CascadingParameter | Value automatically passed down to all descendant components |
| Circuit | SignalR connection between a user and their Blazor Server instance |
| Optimistic Concurrency | Strategy allowing simultaneous edits with conflict detection |
| Pessimistic Concurrency | Strategy locking data to prevent simultaneous modifications |
| DbContextFactory | Service creating thread-safe DbContext instances |
| EventCallback | Blazor type for asynchronous child → parent callbacks |
| IDbContextFactory | Recommended interface for creating DbContexts in Blazor Server |
| IndexedDB | Browser-side database with more capacity than localStorage |
| IMemoryCache | ASP.NET Core interface for in-memory caching |
| Lazy loading | Strategy loading data only when needed |
| localStorage | Persistent browser storage (survives browser close) |
| Migration | EF Core code applying schema changes to the database |
| RowVersion | DB column for optimistic concurrency conflict detection |
| Scoped | Service lifetime: one instance per HTTP request or SignalR circuit |
| sessionStorage | Browser storage cleared when the tab is closed |
| Singleton | Service lifetime: one instance for the entire application lifetime |
| SQLite | Lightweight database storing data in a .db file |
| StateHasChanged | Blazor method triggering a component re-render |
| Unidirectional data flow | Pattern: data flows parent → child, events flow child → parent |
Additional Resources
- Official documentation: learn.microsoft.com/aspnet/core/blazor/state-management
- EF Core SQLite: learn.microsoft.com/ef/core/providers/sqlite
- Blazor Lifecycle: learn.microsoft.com/aspnet/core/blazor/components/lifecycle
- Related courses: “Blazor Fundamentals” and “Building Web Applications with Blazor” (Gill Cleeren)
Search Terms
asp.net · managing · state · core · blazor · web · ui · c# · .net · development · persistence · components · service · application · blazing · blog · local · localstorage · management · mode · patterns · sqlite · wasm