NashTech Blog

Table of Contents

Writing Blazor Components — A Pragmatic Developer’s Guide

A hands‑on, opinionated guide to building production‑ready Blazor components: patterns, pitfalls, and practical examples.

Most tutorials stop at “Hello, world.” This article is written from a developer’s day‑to‑day perspective: composability, reusability, predictable state, clean APIs, and the stuff that breaks on Fridays. You can copy snippets straight into real projects.


1) Prerequisites & Setup

  • SDK: .NET 8+ (Blazor Server or WebAssembly)
  • Libraries (optional but useful): AntDesign for Blazor (UI), bUnit (tests)
  • Solution layout
    • /src/MyApp/ (host app)
    • /src/MyApp.Components/ (Razor Class Library for components)
    • /tests/MyApp.Components.Tests/ (bUnit tests)

Tip: Put reusable UI in a Razor Class Library (RCL). It keeps app code clean and encourages good APIs.


2) Component Anatomy (what goes where)

Every component is typically split into three concerns:

  1. Markup (.razor) — declarative UI and parameters
  2. Logic (.razor.cs partial) — lifecycle, events, and dependencies
  3. Styles (.razor.css) — component‑scoped CSS isolation

Example: Counter.razor

@namespace MyApp.Components

<div class="counter">
  <span class="value">@Value</span>
  <button class="btn" @onclick="Increment">+ @Step</button>
</div>

@code {
  [Parameter] public int Value { get; set; }
  [Parameter] public int Step { get; set; } = 1;
  [Parameter] public EventCallback<int> ValueChanged { get; set; }

  private async Task Increment()
    => await ValueChanged.InvokeAsync(Value + Step);
}

What it shows:

  • One‑way input via Value
  • Two‑way pattern via ValueChanged
  • No internal state; caller owns the source of truth

3) Parameters, Binding & Events (the stable API trio)

One‑way vs Two‑way

  • One‑way keeps components predictable. Prefer it by default.
  • Two‑way is idiomatic via @bind-Value and the Value/ValueChanged pair.

Two‑way binding contract

[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }

Razor usage

<MyTextBox @bind-Value="Model.Name" />

EventCallback vs Action

  • Use EventCallback (and EventCallback<T>) to avoid sync‑context issues and to interop with Razor’s @bind.
  • Accept Func<Task> only for advanced async composition where you don’t need @bind.

Avoid re‑entrancy bugs

If your handler triggers state changes that cause re‑rendering, prefer:

await InvokeAsync(StateHasChanged);

… inside long‑running callbacks.


4) Templates with RenderFragment (composition > inheritance)

Expose slots for callers to customize parts of your component.

<Card>
  <HeaderTemplate>
    <h3>@Title</h3>
  </HeaderTemplate>
  <BodyTemplate>
    @ChildContent
  </BodyTemplate>
  <FooterTemplate>
    @if (ShowActions) { @Actions }
  </FooterTemplate>
</Card>
[Parameter] public string Title { get; set; } = string.Empty;
[Parameter] public bool ShowActions { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public RenderFragment? HeaderTemplate { get; set; }
[Parameter] public RenderFragment? BodyTemplate { get; set; }
[Parameter] public RenderFragment? FooterTemplate { get; set; }
[Parameter] public RenderFragment? Actions { get; set; }

Generic templates

@typeparam TItem
[Parameter] public IEnumerable<TItem>? Items { get; set; }
[Parameter] public RenderFragment<TItem>? RowTemplate { get; set; }

Usage:

<Grid Items="orders" RowTemplate="order => @<tr><td>@order.Id</td><td>@order.Total</td></tr>" />

5) State & Lifecycle (predictable rendering)

Key methods:

  • OnInitialized[Async] – set up defaults, start async loads
  • OnParametersSet[Async] – respond when parent changes inputs
  • OnAfterRender[Async](firstRender) – DOM/JS interop, measure sizes
  • ShouldRender – micro‑opt render frequency (rarely needed)

Async load pattern

protected override async Task OnInitializedAsync()
{
  _loading = true;
  try
  {
    Data = await _service.GetAsync();
  }
  finally { _loading = false; }
}

Prevent double work
Use firstRender in OnAfterRenderAsync for one‑time JS calls.


6) Forms, Validation & Input Formatting

Blazor ships EditForm, InputText, InputNumber, etc., with DataAnnotations.

<EditForm Model="Model" OnValidSubmit="SaveAsync">
  <DataAnnotationsValidator />
  <ValidationSummary />

  <InputText @bind-Value="Model.Name" />
  <InputNumber @bind-Value="Model.Amount" />
  <button type="submit">Save</button>
</EditForm>

Custom formatted input (e.g., currency)
Create InputDecimal that formats on blur and parses on input.

public class InputDecimal : InputBase<decimal>
{
  protected override bool TryParseValueFromString(string? value, out decimal result, out string? error)
  {
    var ok = decimal.TryParse(value, NumberStyles.Any, CultureInfo.CurrentCulture, out result);
    error = ok ? null : $"'{value}' is not a valid number.";
    return ok;
  }
}

Tip: Keep formatting logic on display events (blur) and keep the underlying value raw for model binding.


7) JS Interop (only where it helps)

[Inject] IJSRuntime JS { get; set; } = default!;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
  if (firstRender)
  {
    await JS.InvokeVoidAsync("myLib.initTooltips");
  }
}
  • Prefer IJSObjectReference for modules and dispose them (IAsyncDisposable).
  • Guard calls behind firstRender or explicit events to avoid repetition.

8) Dependency Injection & Services

Inject abstractions, not concretes:

[Inject] public IWeatherService Weather { get; set; } = default!;

If the component creates data (e.g., dialog), pass callbacks to your parent to commit changes — components shouldn’t own repositories.


9) Performance Playbook

  • One state owner: Avoid duplicating parent state in children.
  • Avoid large List<T> renders: use <Virtualize ItemsProvider=... />.
  • Memoize expensive fragments with small wrapper components.
  • ShouldRender only if profiling proves wasteful re‑renders.
  • key directive for stable identity in loops to prevent DOM churn.
@foreach (var item in Items)
{
  <RowComponent @key="item.Id" Item="item" />
}

10) Polished UX with AntDesign (or any UI kit)

Responsive row with actions

<Row Gutter="8" Align="@RowAlign.Middle">
  <Col Span="12"><Text Strong>GL Code:</Text></Col>
  <Col Span="12"><GLCodeSelector @bind-Value="_gl" /></Col>
</Row>

Collapsible details

<Collapse>
  <Collapse.Panel Key="details" HeaderTemplate="@(() => (MarkupString)"Details")">
    <p>@Details</p>
  </Collapse.Panel>
</Collapse>

Drawer for editors

<Drawer Title="Edit Item" Visible="@_open" Placement="DrawerPlacement.Right" Width="640" OnClose="() => _open=false">
  <EditForm Model="Item" OnValidSubmit="SaveAsync"> ... </EditForm>
</Drawer>

Gotcha: Not every web lib prop exists in the Blazor wrapper (e.g., Ghost on Collapse). Check the wrapper’s API first.


11) Error Handling & Boundaries

  • Wrap risky child sections with ErrorBoundary in .NET 8+
<ErrorBoundary>
  <ChildContent>
    <RiskyComponent />
  </ChildContent>
  <ErrorContent>
    <p>Something went wrong.</p>
  </ErrorContent>
</ErrorBoundary>
  • Log in parents; show friendly messages in children.

12) Accessibility & Localization

  • Use semantic HTML; ensure focus order after dialog/drawer open.
  • Provide aria-* labels for custom controls.
  • Localize text, validation messages, and date/number formats.

13) Packaging Components (RCL & NuGet)

  1. Create Razor Class Library: dotnet new razorclasslib -n MyApp.Components
  2. Add _Imports.razor with using statements used by consumers.
  3. Add wwwroot for static assets.
  4. Pack: dotnet pack -c Release
  5. Publish to a private feed (Azure Artifacts / GitHub Packages / NuGet)

Version your APIs carefully. Breaking changes require a major version bump.


14) Testing with bUnit (fast feedback)

using Bunit;
using Xunit;

public class CounterTests : TestContext
{
  [Fact]
  public void Increments_Value_OnClick()
  {
    var cut = RenderComponent<Counter>(ps => ps
      .Add(p => p.Value, 1)
      .Add(p => p.Step, 2)
      .Add(p => p.ValueChanged, _ => { captured = _; return Task.CompletedTask; })
    );

    cut.Find("button").Click();
    // assert captured == 3
  }
}
  • Render the component, fire events, assert on markup and callbacks.
  • For JS interop, use JSInterop.SetupVoid to avoid real browser dependencies.

15) Real‑World Pattern: Data Grid Row Editor

API goals

  • Parent owns RowModel
  • Child raises OnSave/OnCancel
  • Slots for custom actions
<RowEditor Model="row" OnSave="UpdateRow" OnCancel="Reload">
  <Actions>
    <Button OnClick="Duplicate">Duplicate</Button>
  </Actions>
</RowEditor>
[Parameter] public RowModel Model { get; set; } = default!;
[Parameter] public EventCallback<RowModel> OnSave { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public RenderFragment? Actions { get; set; }

Keep the editor stateless where possible; let the parent decide how the save flows.


16) Common Pitfalls (and fixes)

  • @bind not updating: Ensure Value + ValueChanged pair exists and you call ValueChanged.InvokeAsync(newValue).
  • Double render / double callback: Check if the UI library internally triggers onchange + onblur. Debounce or guard with a flag when necessary.
  • StateHasChanged storms: Batch changes; avoid calling it inside tight loops.
  • Missing parameters: Blazor ignores unknown params. Verify wrapper’s prop names.
  • List edits not reflecting: When editing items in a list, reassign the list reference (new list) if the grid relies on change detection by reference.
  • JS module leaks: Dispose IJSObjectReference in IAsyncDisposable.DisposeAsync.

17) A Minimal, Reusable Component Template

@* File: Components/InputLabel.razor *@
<span class="label @CssClass">@ChildContent</span>

@code {
  [Parameter] public string? CssClass { get; set; }
  [Parameter] public RenderFragment? ChildContent { get; set; }
}
// File: Components/InputLabel.razor.css
.label { font-weight: 600; margin-right: .5rem; }
@* Consumption *@
<InputLabel>GL Code:</InputLabel>
<GLCodeSelector @bind-Value="_glCode" />

18) Release Checklist

  • Public, documented parameters with defaults
  • Clear two‑way bindings where applicable
  • No business logic or persistence inside the component
  • Tests cover events and edge cases
  • a11y pass: focus, labels, keyboard
  • Versioning notes and changelog

19) Final Thoughts

Good components are boring: small, predictable, easy to compose. Enforce a clear contract (parameters + events), keep state minimal, and push domain decisions up to pages or services. That’s how you ship UI that doesn’t fight you.

Want a tailored example (e.g., currency input, grid row editor with AntDesign, or a collapsible GL selector)? Tell me your specific requirements and I’ll adapt the patterns above into a drop‑in component.

Picture of Hao Lam Tan

Hao Lam Tan

Leave a Comment

Your email address will not be published. Required fields are marked *

Suggested Article

Scroll to Top