Theory is fine. Building something real is better. In this article you will construct an Employee Dashboard in Blazor Server that exercises every concept in the Blazor component model: one-way and real-time binding, the full lifecycle, LINQ computed properties, extracted child components, EventCallback-driven communication, cascading theme values, async data loading with a loading state, and a timer cleaned up by IDisposable.

By the end you will have a working dashboard with filtering, sorting, inline status toggling, a selectable employee detail panel, a cascaded colour theme, and a live clock.

1
Create the Project

Scaffold a new Blazor Server application. Blazor Server is the right starting point — all C# runs on the server, there is no WebAssembly download, and SignalR keeps the browser in sync.

dotnet new blazorserver -n EmployeeDashboard
cd EmployeeDashboard
dotnet run

Open Pages/Index.razor. Everything you build goes here and in a Components/ folder you will create alongside it. Delete the default counter and weather content — you are starting from scratch.

2
Define the Data Model

Create Models/Employee.cs. Using a record gives you structural equality for free, which Blazor can use when diffing lists.

// Models/Employee.cs
namespace EmployeeDashboard.Models;

public record Employee(
    int      Id,
    string   Name,
    string   Department,
    string   Role,
    decimal  Salary,
    bool     IsActive,
    DateTime JoinedDate
);

public class AppTheme
{
    public string Name         { get; set; } = "light";
    public string PrimaryColor { get; set; } = "#0078d4";
    public string CardBg       { get; set; } = "#ffffff";
    public string BorderColor  { get; set; } = "#e2e8f0";
}

The AppTheme class will be cascaded to every child component so they can style themselves consistently without prop-drilling.

3
Create the EmployeeCard Component

Create Components/EmployeeCard.razor. This child component receives an employee, knows whether it is selected, and raises callbacks for selection and status changes. It also receives the theme via cascading.

@* Components/EmployeeCard.razor *@
@using EmployeeDashboard.Models

<div class="employee-card @(IsSelected ? "employee-card--selected" : "")"
     style="background: @(Theme?.CardBg ?? "#fff"); border-color: @(Theme?.BorderColor ?? "#e2e8f0");"
     @onclick="HandleSelect">
    <div class="employee-card__header">
        <h3>@Employee.Name</h3>
        <span class="badge @BadgeClass">@BadgeLabel</span>
    </div>
    <p>@Employee.Department — @Employee.Role</p>
    <p>Salary: $@Employee.Salary.ToString("N0")</p>
    <button @onclick:stopPropagation="true"
            @onclick="HandleToggle">
        @(Employee.IsActive ? "Deactivate" : "Activate")
    </button>
</div>

@code {
    [CascadingParameter] public AppTheme?  Theme      { get; set; }

    [Parameter, EditorRequired]
    public Employee Employee  { get; set; } = default!;

    [Parameter] public bool                IsSelected { get; set; }
    [Parameter] public EventCallback<Employee> OnSelect  { get; set; }
    [Parameter] public EventCallback<Employee> OnToggle  { get; set; }

    private string BadgeClass => Employee.IsActive ? "badge--green" : "badge--red";
    private string BadgeLabel => Employee.IsActive ? "Active" : "Inactive";

    private async Task HandleSelect() => await OnSelect.InvokeAsync(Employee);
    private async Task HandleToggle() => await OnToggle.InvokeAsync(Employee);
}

Key decisions: @onclick:stopPropagation="true" on the toggle button prevents a click from also triggering the card's select handler. Computed properties BadgeClass and BadgeLabel keep the template clean.

4
Add the Employee List to Index

Populate Pages/Index.razor with eight sample employees across four departments. Declare them as a mutable list so status toggles can modify them.

// Inside @code in Index.razor
private List<Employee> employees = new()
{
    new(1,  "Alice Chen",    "Engineering", "Senior Engineer",   95000m, true,  new DateTime(2021, 3, 15)),
    new(2,  "Bob Martinez",  "Engineering", "Junior Engineer",   65000m, true,  new DateTime(2023, 7, 1)),
    new(3,  "Carol Smith",   "HR",          "HR Manager",        78000m, true,  new DateTime(2019, 11, 20)),
    new(4,  "David Lee",     "HR",          "HR Coordinator",    52000m, false, new DateTime(2022, 1, 10)),
    new(5,  "Emma Wilson",   "Finance",     "Financial Analyst", 82000m, true,  new DateTime(2020, 5, 8)),
    new(6,  "Frank Brown",   "Finance",     "Accountant",        70000m, true,  new DateTime(2021, 9, 14)),
    new(7,  "Grace Taylor",  "Design",      "UX Lead",           88000m, true,  new DateTime(2020, 2, 28)),
    new(8,  "Henry Johnson", "Design",      "Visual Designer",   72000m, false, new DateTime(2022, 4, 5)),
};

These eight employees span Engineering, HR, Finance, and Design — enough variety to make the department filter interesting.

5
Add Filtering and Sorting with LINQ

Never filter inside @foreach. Declare a computed property that chains LINQ operators. The template loops over the result — clean, readable, and O(n) on the filtered set.

private string searchText      = "";
private string filterDepartment = "All";
private string sortColumn       = "Name";

// Computed: runs at render time, not at declaration
private IEnumerable<Employee> FilteredEmployees =>
    employees
        .Where(e => filterDepartment == "All" || e.Department == filterDepartment)
        .Where(e => string.IsNullOrWhiteSpace(searchText)
                    || e.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase)
                    || e.Role.Contains(searchText, StringComparison.OrdinalIgnoreCase))
        .OrderBy(e => sortColumn switch
        {
            "Salary"     => (object)e.Salary,
            "Department" => e.Department,
            _            => e.Name
        });
@* Bind search real-time (oninput), department on change (default blur) *@
<input @bind="searchText" @bind:event="oninput" placeholder="Search name or role…" />

<select @bind="filterDepartment">
    <option value="All">All Departments</option>
    @foreach (var dept in employees.Select(e => e.Department).Distinct().OrderBy(d => d))
    {
        <option value="@dept">@dept</option>
    }
</select>

<select @bind="sortColumn">
    <option value="Name">Sort: Name</option>
    <option value="Salary">Sort: Salary</option>
    <option value="Department">Sort: Department</option>
</select>
6
Wire Up EventCallback

When a card is clicked, the child raises OnSelect. The parent stores the selection. IsSelected is computed in the parent and passed back down — the child never owns selection state.

private Employee? selectedEmployee;

private void HandleEmployeeSelected(Employee emp)
    => selectedEmployee = emp;
@* Loop over FilteredEmployees — not the raw list *@
@foreach (var emp in FilteredEmployees)
{
    <EmployeeCard Employee="emp"
                  IsSelected="selectedEmployee?.Id == emp.Id"
                 
                  />
}

@* Detail panel — appears on selection *@
@if (selectedEmployee is not null)
{
    <div class="detail-panel">
        <h2>@selectedEmployee.Name</h2>
        <p>Department: @selectedEmployee.Department</p>
        <p>Role: @selectedEmployee.Role</p>
        <p>Salary: $@selectedEmployee.Salary.ToString("N0")</p>
        <p>Joined: @selectedEmployee.JoinedDate.ToString("MMMM dd, yyyy")</p>
        <p>Status: @(selectedEmployee.IsActive ? "Active" : "Inactive")</p>
    </div>
}

Because EventCallback automatically triggers a parent re-render, clicking a card immediately highlights it and updates the detail panel — no manual StateHasChanged() call.

7
Implement Inline Status Toggle

The toggle button in each card raises OnToggle. The parent handler flips the employee's IsActive field. Because Employee is a record (immutable), we replace it in the list rather than mutating it directly.

private void HandleStatusToggle(Employee emp)
{
    var index = employees.FindIndex(e => e.Id == emp.Id);
    if (index < 0) return;

    // Records are immutable — use 'with' expression to create an updated copy
    employees[index] = emp with { IsActive = !emp.IsActive };

    // Keep the detail panel in sync if this employee is selected
    if (selectedEmployee?.Id == emp.Id)
        selectedEmployee = employees[index];
}

The with expression is a record feature: it creates a copy of the record with only the specified properties changed. All other properties retain their original values. No need to re-construct the record from scratch.

8
Add a Cascading Theme

Wrap the dashboard in <CascadingValue>. EmployeeCard already has [CascadingParameter] AppTheme? Theme — no other changes needed in intermediate components.

@* In Index.razor template *@
<CascadingValue Value="currentTheme">
    <div class="dashboard">
        <div class="theme-switcher">
            <button @onclick='() => SetTheme("light")'>Light</button>
            <button @onclick='() => SetTheme("dark")'>Dark</button>
            <button @onclick='() => SetTheme("purple")'>Purple</button>
        </div>

        @* ... filters, cards, detail panel ... *@
    </div>
</CascadingValue>
private AppTheme currentTheme = new() { Name = "light", PrimaryColor = "#0078d4",
                                         CardBg = "#ffffff", BorderColor = "#e2e8f0" };

private void SetTheme(string name)
{
    currentTheme = name switch
    {
        "dark"   => new() { Name = "dark",   PrimaryColor = "#6366f1",
                             CardBg = "#1e1e2e", BorderColor = "#44475a" },
        "purple" => new() { Name = "purple", PrimaryColor = "#a855f7",
                             CardBg = "#faf5ff", BorderColor = "#d8b4fe" },
        _        => new() { Name = "light",  PrimaryColor = "#0078d4",
                             CardBg = "#ffffff", BorderColor = "#e2e8f0" }
    };
}

Clicking a theme button calls SetTheme, which replaces currentTheme with a new object. Blazor detects the new object reference, re-renders the CascadingValue, and every EmployeeCard re-renders with the new colours — zero prop-drilling.

9
Implement OnInitializedAsync with a Loading State

In a real application, employees come from an HTTP API. Simulate the latency with Task.Delay and show a loading indicator while the data arrives.

private bool   isLoading    = true;
private string loadError    = "";

protected override async Task OnInitializedAsync()
{
    try
    {
        isLoading = true;
        // Simulate a 800ms API call
        await Task.Delay(800);
        // In production: employees = await EmployeeService.GetAllAsync();
        // For now, the list is already populated — just flip the flag.
    }
    catch (Exception ex)
    {
        loadError = $"Failed to load employees: {ex.Message}";
    }
    finally
    {
        isLoading = false;
    }
}
@if (isLoading)
{
    <div class="loading-spinner">
        <p>Loading employee data…</p>
    </div>
}
else if (!string.IsNullOrEmpty(loadError))
{
    <p class="error-message">@loadError</p>
}
else
{
    @* ... rest of dashboard ... *@
}

Blazor renders the component twice: once immediately (shows the spinner), and again after the await completes (shows the data). No extra code required — the lifecycle handles it.

10
Add a Live Clock with IDisposable

A dashboard clock refreshes every second via a timer. The timer fires on a thread-pool thread, so updates must be marshalled back via InvokeAsync. Implementing IDisposable stops the timer when the page is navigated away from.

@* At the top of Index.razor *@
@implements IDisposable
private string currentTime = "";
private System.Timers.Timer? _clockTimer;

protected override void OnInitialized()
{
    currentTime  = DateTime.Now.ToString("HH:mm:ss");
    _clockTimer  = new System.Timers.Timer(1000);
    _clockTimer.Elapsed += async (_, _) =>
    {
        currentTime = DateTime.Now.ToString("HH:mm:ss");
        // Must marshal to the Blazor sync context — timer fires on a thread-pool thread
        await InvokeAsync(StateHasChanged);
    };
    _clockTimer.Start();
}

public void Dispose()
{
    _clockTimer?.Stop();
    _clockTimer?.Dispose();
}
@* In the dashboard header *@
<p class="dashboard-clock">@currentTime</p>

Without Dispose, the timer would keep running after the user navigates away, calling StateHasChanged on a component that no longer exists — a silent memory leak. IDisposable is the contract Blazor uses to know when to clean up.

What You Built

The finished dashboard demonstrates every Blazor component model concept in a single, cohesive application:

  • Binding — one-way for display values, @bind for the department and sort dropdowns, @bind:event="oninput" for the live search.
  • Lifecycle — OnInitialized for the clock timer, OnInitializedAsync for async data loading with an isLoading state.
  • LINQ — FilteredEmployees computed property chains Where, OrderBy, and a switch expression without a single @if in the loop.
  • Components and Parameters — EmployeeCard with [Parameter, EditorRequired], computed badge properties, and one-way data flow.
  • EventCallback — OnSelect and OnToggle on EmployeeCard; both automatically trigger parent re-renders, no StateHasChanged() needed.
  • Cascading Values — AppTheme cascaded from Index to every EmployeeCard via one <CascadingValue> wrapper; a theme switcher changes all cards simultaneously.
  • IDisposable — the clock timer is stopped and disposed when the component is removed from the tree.

Key Takeaways

  • A real-world Blazor page combines all six component model concepts — they are not independent skills, they work together.
  • The parent owns all shared state (selection, theme, employee list); child components display and raise events.
  • LINQ computed properties make the template a pure view — no logic in the loop, no @if inside @foreach.
  • EventCallback removes the need for manual StateHasChanged() on every user interaction.
  • One <CascadingValue> wrapper propagates a theme to an entire subtree — no prop-drilling through intermediate components.
  • Every timer, subscription, or disposable resource must be cleaned up in IDisposable.Dispose.