Think you need mountains of JavaScript to build interactive web apps? Let me show you how far you can get with just C# and Razor. In one sprint, my team replaced a jQuery jungle with tidy Blazor components, and our bug count dropped by 40%. In this article, you’ll learn the patterns we used – component composition, one‑way and two‑way binding, validation, templating, and a few gotchas that trip up even seasoned .NET devs.
Blazor in 60 seconds (the essentials)
Blazor lets you write interactive UI in C# using Razor components. A component is a .razor
file (optionally with a code‑behind .razor.cs
) that:
- Renders HTML
- Holds state (fields/properties)
- Raises events (methods)
- Accepts parameters from its parent
Blazor re‑renders components when state changes. Think of it as React, but in C#. You focus on state → UI and let the framework reconcile DOM updates.
A simple data‑flow mental model:
Parent State --> [parameters] --> Child Component UI
^ |
| v
EventCallbacks <------ user input / child events
Quick start: project layout
You can drop these components into any Blazor app (Server or WebAssembly). Typical structure:
/Pages
Index.razor
/Components
CounterBox.razor
DebouncedSearch.razor
ValidatedPersonForm.razor
TemplatedList.razor
InputPhone.razor (+ InputPhone.razor.cs)
/Shared
AppState.cs
Tip: Prefer code‑behind (
.razor.cs
) for complex logic to keep markup clean.
Binding fundamentals you’ll actually use
One‑way binding (display‑only)
Use @ expressions to print values. Re‑render happens when the component’s state changes via StateHasChanged()
or as part of normal event handling.
<p>Total items: @TotalCount</p>
Event binding (user actions → methods)
Wire events like @onclick
, @oninput
, etc.
<button @onclick="Add">Add</button>
Two‑way binding (@bind
)
Use @bind
to keep a value in sync with an input.
<input @bind="Name" />
You can control the event source:
<input @bind="Query" @bind:event="oninput" /> <!-- updates as you type -->
You can also format (for display) numeric/date values:
<input type="number" @bind="Price" @bind:format="0.00" step="0.01" />
Component two‑way binding (parent ↔ child)
A child can expose a bindable parameter by following the trio convention: Value
, ValueChanged
, and ValueExpression
(the last one is mainly for forms/validation). Then a parent can use @bind-Value
.
Example 1: A clean, reusable counter with component binding
CounterBox.razor (child):
<div class="flex items-center gap-2">
<button class="btn" @onclick="() => OnChange(Value - Step)">−</button>
<span class="px-3">@Value</span>
<button class="btn" @onclick="() => OnChange(Value + Step)">+</button>
</div>
@code {
[Parameter] public int Value { get; set; }
[Parameter] public EventCallback<int> ValueChanged { get; set; }
[Parameter] public int Step { get; set; } = 1;
private async Task OnChange(int next)
=> await ValueChanged.InvokeAsync(next);
}
Parent usage (Index.razor):
@page "/"
<h3>Cart quantity</h3>
<CounterBox @bind-Value="quantity" Step="2" />
<p>Subtotal: @(quantity * 19.99M)</p>
@code {
private int quantity = 1;
}
Why this pattern? It scales. Later you can add min/max, disable states, etc., without changing parents that bind with @bind-Value
.
Example 2: Controlled input with one‑way data flow + events
Sometimes you want validation before state changes (think: trimming, limits, business rules). Use one‑way props + explicit events.
NameEditor.razor (child):
<input value="@Draft" @oninput="OnInput" />
<button @onclick="Confirm" disabled="@IsInvalid">Save</button>
@code {
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
private string? Draft;
protected override void OnParametersSet() => Draft = Value;
private void OnInput(ChangeEventArgs e)
{
Draft = e.Value?.ToString()?.Trim();
}
private bool IsInvalid => string.IsNullOrWhiteSpace(Draft);
private async Task Confirm()
{
if (!IsInvalid)
await ValueChanged.InvokeAsync(Draft);
}
}
Parent:
<NameEditor Value="@customerName" ValueChanged="(v => customerName = v)" />
<p>Hello, @customerName</p>
@code {
private string? customerName = "Ada";
}
This pattern is great when input must be approved before syncing back.
Example 3: Forms and validation the right way
Use EditForm
+ DataAnnotationsValidator
for built‑in validation.
Person.cs (model):
public sealed class Person
{
[Required, StringLength(50)]
public string FirstName { get; set; } = string.Empty;
[Required, StringLength(50)]
public string LastName { get; set; } = string.Empty;
[Range(0, 130)]
public int Age { get; set; }
[EmailAddress]
public string? Email { get; set; }
}
ValidatedPersonForm.razor:
<EditForm Model="person" OnValidSubmit="HandleValid">
<DataAnnotationsValidator />
<ValidationSummary />
<label>First name <InputText @bind-Value="person.FirstName" /></label>
<label>Last name <InputText @bind-Value="person.LastName" /></label>
<label>Age <InputNumber @bind-Value="person.Age" /></label>
<label>Email <InputText @bind-Value="person.Email" /></label>
<button type="submit">Save</button>
</EditForm>
@if (saved != null)
{
<p class="ok">Saved: @saved.FirstName @saved.LastName (@saved.Age) – @saved.Email</p>
}
@code {
private readonly Person person = new();
private Person? saved;
private void HandleValid() => saved = new Person
{
FirstName = person.FirstName,
LastName = person.LastName,
Age = person.Age,
Email = person.Email
};
}
Tip: Use
Input*
components inside forms. They integrate with validation andValueExpression
automatically.
Example 4: Debounced search input (no third‑party libs)
Avoid firing a request on every keystroke. Debounce it.
DebouncedSearch.razor:
<input value="@query" @oninput="OnInput" placeholder="Search..." />
<ul>
@foreach (var item in results)
{
<li>@item</li>
}
</ul>
@code {
private string query = string.Empty;
private List<string> results = new();
private CancellationTokenSource? cts;
private void OnInput(ChangeEventArgs e)
{
query = e.Value?.ToString() ?? string.Empty;
RestartDebounce();
}
private void RestartDebounce()
{
cts?.Cancel();
cts = new CancellationTokenSource();
var token = cts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(350, token); // debounce
var data = await SearchAsync(query, token);
await InvokeAsync(() =>
{
results = data;
StateHasChanged();
});
}
catch (OperationCanceledException) { }
}, token);
}
private static Task<List<string>> SearchAsync(string q, CancellationToken token)
{
// Fake backend: contains, case‑insensitive
var all = new[] { "Blazor", "Binding", "Components", "Validation", "Templates" };
var res = all.Where(x => x.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
return Task.FromResult(res);
}
}
Key ideas:
- Use
CancellationTokenSource
to cancel prior work - Use
InvokeAsync
to marshal results to the UI thread
Example 5: Templated list component (RenderFragment)
A reusable list that lets the caller define how each item renders.
TemplatedList.razor:
<ul>
@if (Items != null)
{
@foreach (var item in Items)
{
<li>@ItemTemplate(item)</li>
}
}
</ul>
@code {
[Parameter] public IEnumerable<TItem>? Items { get; set; }
[Parameter] public RenderFragment<TItem> ItemTemplate { get; set; } = default!;
[EditorRequired]
[Parameter] public RenderFragment? EmptyTemplate { get; set; }
[Parameter] public RenderFragment? HeaderTemplate { get; set; }
[Parameter] public RenderFragment? FooterTemplate { get; set; }
}
@typeparam TItem
Usage:
<TemplatedList Items="products">
<HeaderTemplate>
<h4>Products (@products.Count)</h4>
</HeaderTemplate>
<ItemTemplate Context="p">
<article>
<strong>@p.Name</strong> – @p.Price.ToString("C")
</article>
</ItemTemplate>
<EmptyTemplate>
<em>No results</em>
</EmptyTemplate>
</TemplatedList>
@code {
private readonly List<Product> products = new()
{
new("USB‑C Cable", 9.99m),
new("Mechanical Keyboard", 79m)
};
private sealed record Product(string Name, decimal Price);
}
This pattern scales to complex UIs (tables, cards) with full caller control.
Example 6: Cascading state (lightweight state container)
Share app‑wide state without prop‑drilling using CascadingValue
.
AppState.cs:
public sealed class AppState
{
public event Action? Changed;
private string? _theme;
public string? Theme
{
get => _theme;
set { _theme = value; Changed?.Invoke(); }
}
}
In App.razor
(or MainLayout):
<CascadingValue Value="appState">
<Router AppAssembly="@typeof(App).Assembly" />
</CascadingValue>
@code {
private readonly AppState appState = new();
}
Any descendant component:
@code {
[CascadingParameter] public AppState App { get; set; } = default!;
protected override void OnInitialized()
=> App.Changed += StateHasChanged;
public void Dispose() => App.Changed -= StateHasChanged;
}
Now changing App.Theme = "dark";
re‑renders all consumers.
Be mindful: cascading too much state can re‑render large trees. Consider scoping.
Example 7: A proper custom input (InputBase)
When you need validation + EditForm
integration for a custom control, inherit InputBase<T>
.
InputPhone.razor:
<input value="@CurrentValueAsString" @oninput="OnInput" placeholder="(555) 123‑4567" />
InputPhone.razor.cs:
public partial class InputPhone : InputBase<string>
{
protected override bool TryParseValueFromString(string? value, out string result, out string? validationErrorMessage)
{
// keep digits only, format as (XXX) XXX-XXXX when possible
var digits = new string((value ?? string.Empty).Where(char.IsDigit).ToArray());
if (digits.Length == 0)
{
result = string.Empty;
validationErrorMessage = null;
return true;
}
if (digits.Length < 10)
{
result = digits;
validationErrorMessage = "Phone number is incomplete.";
return false;
}
var area = digits[..3];
var mid = digits.Substring(3, 3);
var last = digits.Substring(6, 4);
result = $"({area}) {mid}-{last}";
validationErrorMessage = null;
return true;
}
protected override string FormatValueAsString(string? value) => value ?? string.Empty;
private void OnInput(ChangeEventArgs e)
=> CurrentValueAsString = e.Value?.ToString() ?? string.Empty;
}
Usage inside a form:
<EditForm Model="order" OnValidSubmit="Submit">
<DataAnnotationsValidator />
<label>Phone <InputPhone @bind-Value="order.Phone" /></label>
<ValidationMessage For="() => order.Phone" />
<button type="submit">Place Order</button>
</EditForm>
@code {
private Order order = new();
private void Submit() { /* ... */ }
private sealed class Order
{
[Required]
public string Phone { get; set; } = string.Empty;
}
}
This way you get validation, ValueExpression
, and form integration for free.
Bonus patterns you’ll thank yourself for later
@key
for list diffs: When looping, add@key
to stabilize identity and avoid janky re‑renders.@foreach (var p in products) { <ProductCard @key="p.Id" Model="p" /> }
- Don’t block the UI: Make event handlers
async Task
and await I/O. Use spinners/disabled states. EventCallback
overAction
:EventCallback
handles async & re‑render semantics better (and avoids sync context pitfalls).- Prefer small components: Compose small, focused components; avoid 500‑line monoliths. Your future self will send cookies.
- Lift state up sparingly: Bind as low as possible; only lift state when multiple children need it.
- Be explicit with culture/format: For currency/date inputs, format for the user’s culture (
CultureInfo
) or show placeholders. - Testing: For logic‑heavy components, use bUnit to test markup and interactions.
Common pitfalls (and quick fixes)
- Two‑way binding doesn’t work. Did you expose
Value
andValueChanged
in the child? Parent must use@bind-Value
. EditForm
shows errors but doesn’t update. UseInput*
components (not plain<input>
) for validated fields.- UI doesn’t refresh after async work. Use
await InvokeAsync(StateHasChanged)
when updating from background threads. - Infinite re‑renders. Avoid setting state inside
OnParametersSet
without guards; useOnParametersSetAsync
carefully. - Too many renders while typing. Use
@bind:event="oninput"
only when you need live updates; otherwise defaultchange
event is lighter.
End‑to‑end mini‑feature: Product editor
Let’s stitch patterns together.
ProductEditor.razor:
<EditForm Model="model" OnValidSubmit="SaveAsync">
<DataAnnotationsValidator />
<label>Name <InputText @bind-Value="model.Name" /></label>
<label>Price <InputNumber @bind-Value="model.Price" /></label>
<label>Quantity <CounterBox @bind-Value="model.Quantity" Step="1" /></label>
<label>Category
<select @bind="model.Category">
@foreach (var c in Categories)
{
<option value="@c">@c</option>
}
</select>
</label>
<button disabled="@saving" type="submit">@(saving ? "Saving..." : "Save")</button>
</EditForm>
@code {
private bool saving;
private readonly Product model = new();
private static readonly string[] Categories = ["Cables", "Keyboards", "Monitors"];
private async Task SaveAsync()
{
saving = true;
// pretend to call API
await Task.Delay(600);
saving = false;
}
private sealed class Product
{
[Required, StringLength(100)] public string Name { get; set; } = string.Empty;
[Range(0, 9999)] public decimal Price { get; set; }
[Range(0, 9999)] public int Quantity { get; set; }
[Required] public string Category { get; set; } = "Cables";
}
}
This showcases: templated input (CounterBox), form validation, select binding, and async submit.
Performance & maintainability checklist
- Minimize re‑renders:
- Use
@key
in loops. - Avoid creating new delegates/objects in markup inside large loops; hoist them.
- Use
- Async all the way: Don’t block on I/O (
.Result
/.Wait()
); it freezes Blazor Server circuits. - State boundaries: Keep state close to components; use
CascadingValue
only when necessary. - Code‑behind for logic: Complex methods →
.razor.cs
to keep markup tiny. - Dispose properly: Unsubscribe events in
Dispose()
to avoid memory leaks. - Measure first: Use browser dev tools and
dotnet-counters
/Application Insights for real perf signals.
FAQ: Blazor binding & components
@bind
and @bind-Value
?@bind
is shorthand for inputs (binds their value
attribute). @bind-Value
targets a specific parameter/property, often on a component (using the Value
/ValueChanged
pair).
EventCallback
vs Action
?Prefer EventCallback
for component events – it supports async handlers and integrates with the render pipeline.
Call await InvokeAsync(StateHasChanged)
or update state inside an event handler. On Blazor Server this is crucial to avoid thread‑safety issues.
Yes. Input*
components support expressions like @bind-Value="model.Address.City"
. For performance, mutate a copy then assign to avoid deep change storms.
Use controlled input (manual @oninput
) for the field driving search; keep validated fields as Input*
so forms still validate normally.
Add @key
to each loop item element or component so Blazor preserves identity.
Feature folders scale better: group components by feature (e.g., /Products/Editor
, /Products/List
).
Conclusion: Build faster by mastering the few patterns that matter
You don’t need a framework zoo to ship rich web UIs. With a handful of Blazor component patterns – one‑way/event/two‑way binding, templating, validation, and small state containers – you can compose features quickly and keep them maintainable. My challenge to you: pick one example above (the debounced search is a fan favorite), drop it in your project today, and tell me what trimmed the most code. Your future self – and your bug tracker – will say thanks.
What’s the next Blazor binding puzzle you want me to break down? Drop it in the comments and I’ll expand this series.
Great article! Your step-by-step guide on creating Blazor components was super helpful. Made my first component in minutes!