Mastering Blazor Components and Data Binding: A Beginner's Guide

Learning Blazor Components & Data Binding (Examples)

Learn Blazor components and data binding with practical C# examples: forms, two‑way bind, templates, validation, and debounced input.

.NET Development·By amarozka · August 22, 2025

Learning Blazor Components & Data Binding (Examples)

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.

1 Comment

  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 *