
Author Component
- 7 installs
- 466 repo stars
- Updated July 25, 2026
- managedcode/dotnet-skills
Creates or reviews Blazor components with correct architecture: parameters, EventCallback, RenderFragment slots, lifecycle, async patterns, and CSS isolation.
About
A skill for authoring and reviewing Blazor .razor components with correct data-down/events-up architecture. A developer uses it to write non-JS-interop components, parameters, RenderFragment slots, and lifecycle code.
- Data flows down via [Parameter], events up via EventCallback
- Never mutate parameters; use @key and IReadOnlyList for collections
Author Component by the numbers
- 7 all-time installs (skills.sh)
- Ranked #114 of 153 .NET & C# skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/managedcode/dotnet-skills --skill author-componentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 466 |
| Last updated | July 25, 2026 |
| Repository | managedcode/dotnet-skills ↗ |
What it does
Creates or reviews Blazor components with correct architecture: parameters, EventCallback, RenderFragment slots, lifecycle, async patterns, and CSS isolation.
Files
Author Blazor Component
Core Rules
- Data flows down via
[Parameter]. Events flow up viaEventCallback<T>(neverAction/Func). - Never mutate
[Parameter]properties. Copy to a private field inOnParametersSet. - Use
[Parameter] public T Prop { get; set; }— neverrequiredorinit(causes BL0007). - Use
[EditorRequired]for required parameters. - Handle all states: loading, empty, loaded, error — each with
@if/@else. - Use
@keyon repeated elements in loops for efficient diffing. - Use
IReadOnlyList<T>(notIEnumerable<T>) for collection parameters.
RenderFragment & Generics
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public RenderFragment<TItem>? RowTemplate { get; set; } // generic templateUse @typeparam TItem for generic components.
File Patterns
- Single-file:
.razorwith@codeblock when logic < ~50 lines. - Code-behind:
.razor+.razor.cswithpartial classwhen logic > ~50 lines.
Disposal
Implement IAsyncDisposable (not IDisposable) when the component owns subscriptions, timers, or CTS. In DisposeAsync: unsubscribe (-=), cancel CTS, dispose resources. Never call StateHasChanged.
Async Patterns
awaitevery async operation. Never use.Result,.Wait(),Task.Run,ContinueWith,Thread.Start.- Debounce:
Task.Delay+CancellationTokenSource. Cancel old CTS, create new, await delay, do work. Never useSystem.Threading.TimerorSystem.Timers.Timer. - Polling: Loop in
OnInitializedAsyncwithawait Task.Delay(interval, token)— stays on sync context. - External events (
Action<T>): Useasync voidhandler +await InvokeAsync(() => { state++; StateHasChanged(); })+catch→DispatchExceptionAsync. Never_ = InvokeAsync(...). - Cancel CTS in
DisposeAsync. Don't catchObjectDisposedException— use CTS cancellation.
Don'ts
required/initon[Parameter]— runtime failure- Mutate
[Parameter]— copy to private field inOnParametersSet Action/Funcfor events — useEventCallback<T>Task.Run/.Result/.Wait()/Timer for debounce — deadlock or thread-pool escape- Inline
styleattributes — use CSS classes ordata-*attributes catch { throw; }— usewhenguard or let exceptions propagate- Gold-plating: ARIA, wrapper divs, accessibility features not requested
_ = InvokeAsync(...)— swallows exceptions; useasync void+DispatchExceptionAsync
{
"version": "0.1.0",
"category": "Core",
"compatibility": "Requires a .NET repository or solution."
}
Async Programming Rules
Blazor's sync context guarantees single-threaded component execution. All rules below follow from this.
Await every Task
await every Task by default — discarded tasks silently lose exceptions. The only exception: fire-and-forget where the called method wraps its body in try/catch and routes errors via DispatchExceptionAsync (see Fire-and-Forget section below).
// DO
private async Task LoadData()
{
items = await Http.GetFromJsonAsync<List<Item>>("api/items");
}
// DON'T — fire-and-forget hides exceptions
private void LoadData()
{
_ = Http.GetFromJsonAsync<List<Item>>("api/items");
}Forbidden Primitives
These deadlock or escape the sync context. Never use in components:
| Forbidden | Why |
|---|---|
Thread.Start / new Thread | Escapes sync context |
Task.Run | Offloads to thread-pool; StateHasChanged throws |
.Result / .Wait() | Deadlocks sync context |
Task.ContinueWith | Continuation runs outside sync context |
Channel<T>, BlockingCollection<T>, concurrent collections | Unnecessary — single-threaded access guaranteed |
// DON'T — Task.Run escapes sync context
_ = Task.Run(async () => {
var result = await OrderService.SubmitAsync(order);
StateHasChanged(); // InvalidOperationException!
});
// DO — stay on sync context
private async Task ProcessOrder()
{
var result = await OrderService.SubmitAsync(order);
message = result.Message;
}StateHasChanged
Framework auto-renders after lifecycle methods and event handlers complete. Don't call StateHasChanged routinely.
Call only for:
1. Intermediate updates between multiple awaits:
private async Task ProcessSteps()
{
status = "Step 1...";
await Step1Async();
status = "Step 2...";
StateHasChanged(); // intermediate update
await Step2Async();
}2. External events (timer, C# event, WebSocket) via InvokeAsync:
private async void OnExternalEvent(object? sender, EventArgs e)
{
try
{
await InvokeAsync(() => { count++; StateHasChanged(); });
}
catch (Exception ex)
{
await DispatchExceptionAsync(ex);
}
}InvokeAsync marshals onto the sync context. StateHasChanged from a raw thread throws InvalidOperationException. Use async void for external event handlers — it's the only place async void is appropriate in Blazor. Always await InvokeAsync and route errors via DispatchExceptionAsync.
Fire-and-Forget
Route errors via DispatchExceptionAsync (activates error boundaries, logs like lifecycle exceptions):
private void SendReport() => _ = SendReportCore();
private async Task SendReportCore()
{
try { await ReportSender.SendAsync(); }
catch (Exception ex) { await DispatchExceptionAsync(ex); }
}Alternatives to Forbidden Primitives
Instead of `Task.Run` — use await directly or Task.Yield:
// Yield to let renderer paint, then continue on sync context
private async Task StartLongOperation()
{
status = "Starting...";
await Task.Yield();
await LongOperationService.RunAsync();
status = "Done!";
}Chunked CPU work — break with Task.Yield so UI stays responsive:
private async Task ProcessLargeList()
{
for (var i = 0; i < items.Count; i++)
{
ProcessItem(items[i]);
if (i % 100 == 0)
{
StateHasChanged();
await Task.Yield();
}
}
}Indivisible long ops — Task.WhenAny + Task.Delay for progress:
private async Task RunLongQuery()
{
var queryTask = DatabaseService.RunExpensiveQueryAsync();
while (queryTask != await Task.WhenAny(queryTask, Task.Delay(1000)))
{
status = "Still working...";
StateHasChanged();
}
result = await queryTask;
}Instead of .Result / .Wait() — use await
// Wrong — blocks the sync context, deadlocks the circuit
private void Load()
{
var data = Http.GetFromJsonAsync<List<Item>>("api/items").Result;
}
// Correct — use async all the way through
private async Task Load()
{
var data = await Http.GetFromJsonAsync<List<Item>>("api/items");
}When the calling context is synchronous and cannot be changed to async (e.g., an interface method that returns void), use fire-and-forget with error handling:
private void Load()
{
_ = LoadAsync();
}
private async Task LoadAsync()
{
try
{
data = await Http.GetFromJsonAsync<List<Item>>("api/items");
StateHasChanged();
}
catch (Exception ex)
{
await DispatchExceptionAsync(ex);
}
}StateHasChanged is required here because the framework does not know about the fire-and-forget task, so it will not trigger a re-render when it completes.
Instead of ConcurrentDictionary / Channel<T> — use plain collections
Because the synchronization context guarantees single-threaded access within a circuit, regular Dictionary<K,V>, List<T>, and Queue<T> are safe. Concurrent collections add overhead with no benefit:
// Wrong — unnecessary overhead, hides the threading model
private readonly ConcurrentDictionary<string, int> cache = new();
// Correct — the sync context already prevents concurrent access
private readonly Dictionary<string, int> cache = [];Instead of Task.ContinueWith — use await with code after it
// Wrong — continuation may run on a thread-pool thread
private void Start()
{
_ = Http.GetFromJsonAsync<List<Item>>("api/items")
.ContinueWith(t =>
{
items = t.Result;
StateHasChanged(); // InvalidOperationException!
});
}
// Correct — straightforward async/await
private async Task Start()
{
items = await Http.GetFromJsonAsync<List<Item>>("api/items");
}Cancelling async work with CancellationToken
Components that start long-running async operations (HTTP calls, database queries, streaming) should cancel that work when the component is disposed — typically when the user navigates away.
Use a CancellationTokenSource that is cancelled in DisposeAsync:
@implements IAsyncDisposable
@inject HttpClient Http
<p>@status</p>
@code {
private string status = "Loading...";
private CancellationTokenSource cts = new();
protected override async Task OnInitializedAsync()
{
try
{
var data = await Http.GetFromJsonAsync<List<Item>>(
"api/items", cts.Token);
status = $"Loaded {data?.Count} items.";
}
catch (OperationCanceledException)
{
// Component was disposed while loading — expected, nothing to do.
}
}
public ValueTask DisposeAsync()
{
cts.Cancel();
cts.Dispose();
return ValueTask.CompletedTask;
}
}Breaking Down Components
Sibling Decomposition
When a component has two independent blocks (no shared state/handlers), extract each as a sibling.
<!-- CardTitle.razor -->
<div class="card-header">
<h3>@Title</h3>
<button @onclick="OnPin">Pin</button>
</div>
@code {
[Parameter, EditorRequired] public string Title { get; set; } = "";
[Parameter] public EventCallback OnPin { get; set; }
}<!-- CardBody.razor -->
<div class="card-body">
<p>@Description</p>
<button @onclick="OnExpand">Read more</button>
</div>
@code {
[Parameter, EditorRequired] public string Description { get; set; } = "";
[Parameter] public EventCallback OnExpand { get; set; }
}<!-- Card.razor — composes siblings -->
<div class="card">
<CardTitle Title="@Title" OnPin="OnPin" />
<CardBody Description="@Description" OnExpand="OnExpand" />
</div>List-Item Extraction
Extract complex item templates into their own component. Use @key for efficient diffing.
<!-- TaskItem.razor -->
<li class="task-item @(Task.IsComplete ? "done" : "")">
<input type="checkbox" checked="@Task.IsComplete"
@onchange="() => OnToggle.InvokeAsync(Task)" />
<span>@Task.Title</span>
<button @onclick="() => OnDelete.InvokeAsync(Task)">Delete</button>
</li>
@code {
[Parameter, EditorRequired] public TaskModel Task { get; set; } = default!;
[Parameter] public EventCallback<TaskModel> OnToggle { get; set; }
[Parameter] public EventCallback<TaskModel> OnDelete { get; set; }
}<!-- TaskList.razor -->
<ul class="task-list">
@foreach (var task in Tasks)
{
<TaskItem @key="task.Id" Task="task"
OnToggle="HandleToggle" OnDelete="HandleDelete" />
}
</ul>Cascading Context
Avoid parameter drilling through intermediate components. Cascade a context object or cascade the parent itself.
<!-- TabSet.razor — cascades itself -->
<CascadingValue Value="this" IsFixed="true">
<ul class="nav nav-tabs">@ChildContent</ul>
</CascadingValue>
<div class="tab-body">@ActiveTab?.ChildContent</div>
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
public ITab? ActiveTab { get; private set; }
public void AddTab(ITab tab) { if (ActiveTab is null) SetActiveTab(tab); }
public void SetActiveTab(ITab tab)
{
if (ActiveTab != tab) { ActiveTab = tab; StateHasChanged(); }
}
}<!-- Tab.razor — receives parent via cascading parameter -->
@implements ITab
<li>
<a @onclick="() => ContainerTabSet?.SetActiveTab(this)"
class="nav-link @(ContainerTabSet?.ActiveTab == this ? "active" : "")">@Title</a>
</li>
@code {
[CascadingParameter] private TabSet? ContainerTabSet { get; set; }
[Parameter] public string? Title { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
protected override void OnInitialized() => ContainerTabSet?.AddTab(this);
}- Mark
IsFixed="true"when the cascaded reference never changes — avoids unnecessary re-renders. - For app-wide values (theme, auth), register via DI:
builder.Services.AddCascadingValue(sp => new ThemeInfo { ... });
Component Disposal
Always use IAsyncDisposable (not IDisposable). Returns ValueTask — works for sync and async cleanup.
When to Implement
Implement when component owns: event subscriptions, timers, CancellationTokenSource, or JS interop references (IJSObjectReference, DotNetObjectReference<T>). Otherwise skip disposal.
Pattern — Sync Cleanup
@implements IAsyncDisposable
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
=> Navigation.LocationChanged += HandleLocationChanged;
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e) { }
public ValueTask DisposeAsync()
{
Navigation.LocationChanged -= HandleLocationChanged;
return ValueTask.CompletedTask;
}
}Pattern — JS Interop Cleanup
@implements IAsyncDisposable
@inject IJSRuntime JS
@code {
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/myModule.js");
}
public async ValueTask DisposeAsync()
{
if (module is not null)
{
try { await module.DisposeAsync(); }
catch (JSDisconnectedException) { } // Circuit already gone
}
}
}Anti-pattern — Timer (Don't)
Prefer Task.Delay polling loops (see SKILL.md). If you must use a timer, use async void to avoid discarding the InvokeAsync task:
@using System.Timers
@implements IAsyncDisposable
@code {
private Timer? timer;
protected override void OnInitialized()
{
timer = new Timer(1000);
timer.Elapsed += OnTimerElapsed;
timer.Start();
}
private async void OnTimerElapsed(object? sender, ElapsedEventArgs e)
{
try
{
await InvokeAsync(() => { count++; StateHasChanged(); });
}
catch (Exception ex)
{
await DispatchExceptionAsync(ex);
}
}
public ValueTask DisposeAsync()
{
timer?.Dispose();
return ValueTask.CompletedTask;
}
}Timer.Elapsed fires on thread-pool thread. async void is the only correct handler signature — it awaits InvokeAsync and routes errors via DispatchExceptionAsync.
Rules
- Don't call
StateHasChangedinDisposeAsync— renderer is tearing down. - Do null-check fields created in lifecycle methods —
DisposeAsyncmay run beforeOnInitializedAsynccompletes. - Do catch
JSDisconnectedExceptionwhen disposing JS refs — circuit may be gone. - Do unsubscribe all event handlers (
-=) — subscriptions on long-lived objects leak the component.