Learning Blazor Components & Data Binding (Examples)

Mastering Blazor Components and Data Binding: A Beginner's Guide
This post is part 2 of 12 in the series Blazor Tutorial: Build Next-Gen Web Applications

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 and ValueExpression 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 over Action: 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 and ValueChanged in the child? Parent must use @bind-Value.
  • EditForm shows errors but doesn’t update. Use Input* 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; use OnParametersSetAsync carefully.
  • Too many renders while typing. Use @bind:event="oninput" only when you need live updates; otherwise default change 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.
  • 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

What’s the difference between @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).

When should I use EventCallback vs Action?

Prefer EventCallback for component events – it supports async handlers and integrates with the render pipeline.

How do I update UI from a background thread?

Call await InvokeAsync(StateHasChanged) or update state inside an event handler. On Blazor Server this is crucial to avoid thread‑safety issues.

Can I bind to complex/nested objects?

Yes. Input* components support expressions like @bind-Value="model.Address.City". For performance, mutate a copy then assign to avoid deep change storms.

How do I debounce without losing validation?

Use controlled input (manual @oninput) for the field driving search; keep validated fields as Input* so forms still validate normally.

My list flickers when items change.

Add @key to each loop item element or component so Blazor preserves identity.

Should I split components by folder/feature or by type?

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.

One thought on “Learning Blazor Components & Data Binding (Examples)

  1. Great article! Your step-by-step guide on creating Blazor components was super helpful. Made my first component in minutes!

Leave a Reply

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