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.
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.
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.
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.
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.
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>
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.
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.
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.
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.
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,
@bindfor the department and sort dropdowns,@bind:event="oninput"for the live search. - Lifecycle —
OnInitializedfor the clock timer,OnInitializedAsyncfor async data loading with anisLoadingstate. - LINQ —
FilteredEmployeescomputed property chainsWhere,OrderBy, and aswitchexpression without a single@ifin the loop. - Components and Parameters —
EmployeeCardwith[Parameter, EditorRequired], computed badge properties, and one-way data flow. - EventCallback —
OnSelectandOnToggleonEmployeeCard; both automatically trigger parent re-renders, noStateHasChanged()needed. - Cascading Values —
AppThemecascaded fromIndexto everyEmployeeCardvia 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
@ifinside@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.