VSATutorialSeptember 2026 · 9 min read

VSA Todo App — Part 13: Blazor Component Architecture with MudBlazor & State Machines

TL;DR

Part 13 dives into the Blazor CRM's component architecture for the VSA Todo feature. We cover the TodoPage.razor state machine pattern (Table/Create/Update/View modes), MudBlazor DataTable with search and client-side pagination, MudForm with FluentValidation, MudDialog for child item CRUD and delete confirmation, and Excel export via ClosedXML with JS interop.

Part 13 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 12|Next: Part 14 →

Help Us Grow

Love this tutorial? Explore our ready-to-use enterprise starter kits built with VSA in .NET 10.

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.

Key Takeaways

  • The state machine pattern (enum + selected data) captures all Blazor page state in two fields — no URL routing needed
  • Child components communicate via EventCallback parameters, keeping parent-child coupling explicit and minimal
  • MudTable with sort labels, avatars, chips, and row-click selection builds a production data grid without JavaScript
  • MudDialog isolates child entity CRUD and delete confirmation — shared dialogs are reusable across features
  • ClosedXML export runs client-side via JS interop and respects the current search filter

Frequently Asked Questions

Q: How to manage UI state in Blazor VSA apps?

Use the state machine pattern: an enum for the current view (Table, Create, Update, View) plus a field for selected data. The root component renders different sub-components based on the state, and children communicate via EventCallback parameters. It's simple, testable, and needs no URL routing.

Q: How to integrate MudBlazor with VSA?

Each feature's components live in its Components/ folder and inject the feature's service via @inject. The service layer (Part 12) wraps HTTP calls, so components use typed methods instead of raw HTTP. MudBlazor components like MudTable, MudForm, and MudDialog provide the UI; the VSA service provides the data.

Q: How to handle child entity CRUD in Blazor dialogs?

Embed a child data table in the parent form, and use MudDialog for create/edit/delete of child items. Each dialog calls the child's dedicated service method (e.g., CreateTodoItemAsync). On success, reload the child list. This keeps the parent page lightweight and avoids reloading the whole Todo.

Q: How to export data from Blazor to Excel?

Use ClosedXML in the component: build the workbook in memory, convert to base64, and call a JS function via JSRuntime.InvokeVoidAsync to trigger the download. Export the filtered (not paginated) data so users get everything they see in search results. Style the header with bold text and a brand-colored fill.

Part 13 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 12|Next: Part 14 →

Ready to study real VSA code?

Every Indotalent product is a complete .NET 10 application built with Vertical Slice Architecture. Complete source code — $21 each.

Explore Products

Looking for a ready-to-use traditional monolithic multilayered clean architecture?

Monolithic Clean Architecture — 1,300+ devs, 500+ forks. Clean Arch + CQRS + Repository Pattern. Free & open source for commercial use.

Star on GitHub