The Blazor CRM's Todo feature uses a clean component architecture built on MudBlazor. Unlike MVC where each page is a separate Razor view, Blazor uses a single page component (TodoPage.razor) that switches between sub-components based on UI state. This part walks through the component tree, the state machine pattern, and how MudBlazor components integrate with the VSA service layer to create a polished, production-ready UI.
The component tree is organized in the feature's Components/ folder, mirroring the VSA principle of co-location. TodoPage.razor is the root — it manages navigation state and renders the appropriate child component. _TodoDataTable.razor handles the list view with search, pagination, and Excel export. _TodoCreateForm.razor and _TodoUpdateForm.razor handle create and edit forms. _TodoItemDataTable.razor, _TodoItemCreateForm.razor, and _TodoItemUpdateForm.razor manage child items. Everything the Todo UI needs lives in one folder.
The State Machine Pattern in TodoPage.razor
Blazor components render based on their state. The Todo page uses an enum to represent four view modes and switches the rendered component accordingly:
@code {
private enum ViewMode { Table, Create, Update, View }
private ViewMode _currentView = ViewMode.Table;
private UpdateTodoRequest? _selectedData;
private void ShowCreate() => _currentView = ViewMode.Create;
private void ShowUpdate(UpdateTodoRequest data, bool isReadOnly)
{
_selectedData = data;
_currentView = isReadOnly ? ViewMode.View : ViewMode.Update;
}
private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; }
}
@if (_currentView == ViewMode.Create)
{
<_TodoCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" />
}
else if (_currentView == ViewMode.Update || _currentView == ViewMode.View)
{
<_TodoUpdateForm Data="_selectedData!"
ReadOnly="@(_currentView == ViewMode.View)"
OnCancel="BackToTable" OnSuccess="HandleSuccess" />
}
else
{
<_TodoDataTable OnAdd="() => ShowCreate()"
OnEdit="(item) => ShowUpdate(item, false)"
OnView="(item) => ShowUpdate(item, true)" />
}
The state machine pattern is elegant: the component's entire UI state is captured by two fields — _currentView and _selectedData. Child components communicate via EventCallback parameters (OnAdd, OnEdit, OnCancel, OnSuccess), keeping parent-child coupling minimal and explicit. This is the VSA principle applied to the UI: each component is self-contained and communicates through clear contracts.
MudDataTable: Search, Sort, and Paginate
The _TodoDataTable.razor uses MudBlazor's MudTable with custom sorting and client-side pagination. The search box filters in memory using the service layer's list response:
<MudTextField @bind-Value="_searchString" Placeholder="Search..."
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" />
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="OnSearchClick">Search</MudButton>
private IEnumerable<GetTodoListResponse> GetFilteredData()
{
if (string.IsNullOrWhiteSpace(_searchString)) return _todos;
return _todos.Where(x =>
(x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) ||
(x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) ||
(x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false));
}
private IEnumerable<GetTodoListResponse> GetPagedData() =>
GetFilteredData().Skip(_skip).Take(_top);
The table uses MudTableSortLabel for sortable columns, MudAvatar with initials for visual identity, and MudChip for status badges (COMPLETED/PENDING). The pagination footer gives the user control over page size (5-1000 rows) and page navigation. The row-click selection pattern (highlighting the selected row) drives the View/Edit/Remove action buttons above the table.
MudForm with FluentValidation
The create form uses MudBlazor's MudForm with the FluentValidation validator bound directly to the model:
<MudForm @ref="_form" Model="_model">
<MudTextField @bind-Value="_model.Name"
For="@(() => _model.Name)"
Validation="@(_validator.ValidateValue())"
Variant="Variant.Outlined"
Placeholder="e.g. Project Launch" />
</MudForm>
@code {
private MudForm _form = default!;
private CreateTodoValidator _validator = new();
private CreateTodoRequest _model = new();
}
The MudDatePicker and MudTimePicker components capture start/end dates and times. The submit button shows a MudProgressCircular spinner while _processing is true, preventing double submission. On success, the parent's OnSuccess callback returns to the table view — the state machine handles the transition.
MudDialog: Child Items and Delete Confirmation
Child item CRUD happens in MudBlazor dialogs, keeping the main page uncluttered. The _TodoItemDataTable.razor is embedded in the update form and manages its own dialog state. Delete confirmation uses a shared _DeleteConfirmation dialog component that displays the record name and requires explicit confirmation. This shared component is reused across every feature in the Blazor CRM — a cross-cutting UI concern implemented once.
Excel Export via ClosedXML and JS Interop
The data table's Excel export runs entirely client-side using ClosedXML in Blazor. The workbook is generated in memory, converted to base64, and handed to JavaScript for download:
using (var workbook = new XLWorkbook())
{
var worksheet = workbook.Worksheets.Add("Todos");
// Header row with bold styling and blue fill
var headerRange = worksheet.Range(1, 1, 1, 6);
headerRange.Style.Font.Bold = true;
headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1");
headerRange.Style.Font.FontColor = XLColor.White;
foreach (var item in GetFilteredData())
{
currentRow++;
worksheet.Cell(currentRow, 1).Value = item.AutoNumber;
worksheet.Cell(currentRow, 2).Value = item.Name;
// ... more columns
}
worksheet.Columns().AdjustToContents();
using (var stream = new MemoryStream())
{
workbook.SaveAs(stream);
var content = Convert.ToBase64String(stream.ToArray());
await JSRuntime.InvokeVoidAsync("downloadFile",
"Todo_List.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
content);
}
}
Notable details: the export respects the current search filter (GetFilteredData() not GetPagedData()), the header uses a professional blue fill, and the JS downloadFile helper triggers the browser download. This is the client-side counterpart to the server-side export we covered in Part 8.