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:
- Markup (.razor) — declarative UI and parameters
- Logic (.razor.cs partial) — lifecycle, events, and dependencies
- 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-Valueand theValue/ValueChangedpair.
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(andEventCallback<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 loadsOnParametersSet[Async]– respond when parent changes inputsOnAfterRender[Async](firstRender)– DOM/JS interop, measure sizesShouldRender– 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
IJSObjectReferencefor modules and dispose them (IAsyncDisposable). - Guard calls behind
firstRenderor 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.
ShouldRenderonly if profiling proves wasteful re‑renders.keydirective 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.,
GhostonCollapse). Check the wrapper’s API first.
11) Error Handling & Boundaries
- Wrap risky child sections with
ErrorBoundaryin .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)
- Create Razor Class Library:
dotnet new razorclasslib -n MyApp.Components - Add
_Imports.razorwith using statements used by consumers. - Add
wwwrootfor static assets. - Pack:
dotnet pack -c Release - 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.SetupVoidto 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)
@bindnot updating: EnsureValue+ValueChangedpair exists and you callValueChanged.InvokeAsync(newValue).- Double render / double callback: Check if the UI library internally triggers
onchange+onblur. Debounce or guard with a flag when necessary. StateHasChangedstorms: 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
IJSObjectReferenceinIAsyncDisposable.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.