Intermediate

Managing State in ASP.NET Core Blazor

State management in Blazor — component state, sharing via services/DI, WASM auto mode and persistence.

Table of Contents

  1. State Management Fundamentals
  2. Local State at the Component Level
  3. Sharing State Between Components
  4. State Management with Services and DI
  5. Blazor WASM and Auto Mode
  6. State Persistence
  7. Enterprise Applications — Advanced Scenarios
  8. Best Practices
  9. Practical Exercises
  10. 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 managementWith proper state management
Inconsistent data between componentsSingle source of truth
State lost during navigationPersistent and consistent state
Hard-to-debug codePredictable data flow
Hard-to-reuse componentsIndependent and testable components
Unpredictable behaviorDeterministic 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 DbContext directly into a Blazor Server component. DbContext is not thread-safe and Blazor Server can execute multiple operations in parallel. Use IDbContextFactory<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:

MechanismScopeDurationSecurityMax Size
Server memory (Scoped)Per circuitSession✅ GoodUnlimited (server)
DatabaseGlobalPermanent✅ ExcellentUnlimited
localStorageBrowserPermanent⚠️ Visible in plaintext~5-10 MB
sessionStorageTabTab close⚠️ Visible in plaintext~5-10 MB
CookiesDomainConfigurable⚠️ Sent to server~4 KB
IndexedDBBrowserPermanent⚠️ 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

ScenarioRecommended mechanism
Single component state (counter)Local @code variables
Data passed from parent to child[Parameter]
Notification from child to parentEventCallback
Theme or app configCascadingParameter or Singleton service
Shared business dataScoped service + DI
Persistence between sessionsDatabase (server) or localStorage (client)
Temporary unsaved draftslocalStorage
Real-time multi-userSignalR + 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:

  1. Create IBlogRepository and BlogRepository
  2. Register with AddScoped<IBlogRepository, BlogRepository>()
  3. Inject into BlogList and PostDetail
  4. BlogList → selects a post → PostDetail displays the details

Exercise 3 — localStorage Persistence (Advanced, ~2h)

Goal: Save unsubmitted drafts locally.

Steps:

  1. Create the EditPost page with [rendermode InteractiveAuto]
  2. On every change, save to localStorage
  3. On load, check if a draft exists
  4. If yes, offer to restore it
  5. On save, clear the draft

Exercise 4 — SQLite Persistence (Advanced, ~2h)

Goal: Replace the in-memory repository with SQLite.

Steps:

  1. Create BlogDbContext with DbSet<BlogPost>
  2. Add migrations: Add-Migration Initial, Update-Database
  3. Implement PersistentBlogRepository
  4. Use IDbContextFactory (not DbContext directly)
  5. Verify data persists after restart

10. Glossary

TermDefinition
ABACAttribute-Based Access Control — access control based on attributes
ApplicationStateC# service holding the application’s shared state
Blazor ServerBlazor mode running code on the server via SignalR
Blazor WASMBlazor mode running code in the browser via WebAssembly
CascadingParameterValue automatically passed down to all descendant components
CircuitSignalR connection between a user and their Blazor Server instance
Optimistic ConcurrencyStrategy allowing simultaneous edits with conflict detection
Pessimistic ConcurrencyStrategy locking data to prevent simultaneous modifications
DbContextFactoryService creating thread-safe DbContext instances
EventCallbackBlazor type for asynchronous child → parent callbacks
IDbContextFactoryRecommended interface for creating DbContexts in Blazor Server
IndexedDBBrowser-side database with more capacity than localStorage
IMemoryCacheASP.NET Core interface for in-memory caching
Lazy loadingStrategy loading data only when needed
localStoragePersistent browser storage (survives browser close)
MigrationEF Core code applying schema changes to the database
RowVersionDB column for optimistic concurrency conflict detection
ScopedService lifetime: one instance per HTTP request or SignalR circuit
sessionStorageBrowser storage cleared when the tab is closed
SingletonService lifetime: one instance for the entire application lifetime
SQLiteLightweight database storing data in a .db file
StateHasChangedBlazor method triggering a component re-render
Unidirectional data flowPattern: data flows parent → child, events flow child → parent

Additional Resources



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

Interested in this course?

Contact us to book it or get a custom training plan for your team.