Blazor Basics: Build Interactive Web UIs with C# Instead of JavaScript
For years, .NET developers had an invisible wall between their backend C# code and the interactive bits of a web page. The moment a button needed to respond to a click, or a form needed real-time validation, you had to context-switch into JavaScript. That wall created bugs at the seam, slowed teams down, and meant your carefully typed C# models had to be reimplemented as loosely-typed JavaScript objects. Blazor tears that wall down completely.
Blazor is Microsoft's answer to the question: 'What if the entire web stack — UI logic, state management, API calls, and validation — could be written in C#?' It achieves this either by running your .NET code directly in the browser via WebAssembly, or by keeping your code on the server and streaming UI updates over a persistent SignalR connection. Either way, your Razor components are the building blocks: self-contained files that mix C# logic with HTML markup, re-render automatically when data changes, and compose together exactly like React or Vue components — but in the language .NET developers already know cold.
By the end of this article you'll understand the difference between Blazor Server and Blazor WebAssembly (and when to pick each), how the component lifecycle controls rendering, how two-way data binding keeps your UI and your model in sync, and how to wire up real event handling. Every concept comes with a complete, runnable example so you can paste it into a new project and see it work immediately.
Blazor Components — The Single File That Does Everything
In Blazor, a component is a .razor file that contains three things in one place: HTML markup that defines the structure, a @code block that holds C# logic, and optional CSS isolation. This is the core mental model shift — stop thinking in terms of separate HTML files, JavaScript files, and C# controllers. A Blazor component is a self-contained unit of UI.
Every component has a lifecycle. When Blazor first renders a component it calls OnInitializedAsync — this is where you load data from an API or a database. After every parameter change, OnParametersSetAsync fires. The framework tracks which properties are bound to the UI and re-renders only the affected DOM nodes when those values change.
Components are also reusable by design. You define a ProductCard component once and drop anywhere in your app. Parameters flow down from parent to child via [Parameter] attributes, keeping your UI predictable and testable.
The key reason Blazor components beat raw Razor Pages for interactive UIs is that a Razor Page requires a full HTTP round-trip to reflect any change. A Blazor component updates in-place, in memory, without reloading the page — just like a React component does, but entirely in C#.
@* ProductCard.razor — a reusable component that displays one product *@ @* Drop it anywhere with: <ProductCard Product="someProduct" OnAddToCart="HandleAdd" /> *@ <div class="product-card"> <h3>@Product.Name</h3> <p class="price">@Product.Price.ToString("C")</p> @* 'C' formats as currency *@ @if (Product.StockCount > 0) { <!-- Button is only rendered when stock exists --> <button class="btn-primary" @onclick="AddToCart">Add to Cart</button> } else { <span class="out-of-stock">Out of Stock</span> } </div> @code { // [Parameter] marks a public property that a parent component can set [Parameter] public required ProductModel Product { get; set; } // EventCallback lets the child notify the parent without tight coupling [Parameter] public EventCallback<ProductModel> OnAddToCart { get; set; } // This method is called when the button fires its @onclick event private async Task AddToCart() { // Invoke the parent's handler and pass the product as the argument await OnAddToCart.InvokeAsync(Product); } // Lifecycle hook — runs once after the component first renders protected override void OnInitialized() { // Good place for lightweight sync setup (no data fetching here) Console.WriteLine($"ProductCard initialized for: {Product.Name}"); } } // --- ProductModel.cs (put this in your Models folder) --- public class ProductModel { public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } public int StockCount { get; set; } }
ProductCard initialized for: Mechanical Keyboard
Rendered HTML (when StockCount > 0):
<div class="product-card">
<h3>Mechanical Keyboard</h3>
<p class="price">$129.99</p>
<button class="btn-primary">Add to Cart</button>
</div>
Two-Way Data Binding and Real-Time Form State with @bind
One of the first things you'll want to do in any web app is connect a form field to a C# object and have changes reflect immediately — in both directions. Type in the box, the C# property updates. Change the C# property in code, the box updates. Blazor calls this two-way binding and it's powered by the @bind directive.
Under the hood, @bind on an is syntactic sugar for wiring up the value attribute to a property AND attaching an onchange event handler that writes the new value back. You can be explicit about timing using @bind:event="oninput" if you want the property to update on every keystroke rather than on blur.
This matters for real-world scenarios like live search boxes, character counters, or dependent dropdowns where changing one field should immediately filter the options in another. Without two-way binding you'd write the same plumbing logic yourself every time.
Beyond simple inputs, EditForm is Blazor's built-in form component that wraps DataAnnotations validation. You get client-side validation for free from the same [Required] and [Range] attributes you already use on your models — no duplicate validation rules in JavaScript.
@page "/checkout" @* This page demonstrates two-way binding, live character count, and form validation *@ <h2>Checkout</h2> @* EditForm links the form to the C# model and handles validation lifecycle *@ <EditForm Model="order" OnValidSubmit="HandleValidSubmit"> <DataAnnotationsValidator /> @* Reads [Required], [Range] etc. from OrderModel *@ <ValidationSummary /> @* Displays all current validation errors *@ <div class="form-group"> <label>Full Name</label> @* @bind wires value <-> order.CustomerName two-ways *@ <InputText @bind-Value="order.CustomerName" class="form-control" /> <ValidationMessage For="@(() => order.CustomerName)" /> </div> <div class="form-group"> <label>Order Notes (@notesRemaining chars remaining)</label> @* @bind:event="oninput" updates on EVERY keystroke, not just on blur *@ <textarea @bind="order.Notes" @bind:event="oninput" class="form-control" maxlength="200"></textarea> </div> <div class="form-group"> <label>Quantity</label> <InputNumber @bind-Value="order.Quantity" class="form-control" /> <ValidationMessage For="@(() => order.Quantity)" /> </div> <button type="submit" class="btn-primary">Place Order</button> </EditForm> @if (orderConfirmed) { <p class="success">Order placed for @order.CustomerName! Qty: @order.Quantity</p> } @code { // The model the form is bound to — all fields live here private OrderModel order = new(); private bool orderConfirmed = false; // Computed property — recalculates every render cycle automatically private int notesRemaining => 200 - (order.Notes?.Length ?? 0); // OnValidSubmit only fires when ALL DataAnnotations pass private async Task HandleValidSubmit() { // In a real app you'd call an API here await Task.Delay(500); // Simulate a network call orderConfirmed = true; } } // --- OrderModel.cs --- using System.ComponentModel.DataAnnotations; public class OrderModel { [Required(ErrorMessage = "Name is required")] [StringLength(100, ErrorMessage = "Name too long")] public string CustomerName { get; set; } = string.Empty; public string? Notes { get; set; } [Range(1, 99, ErrorMessage = "Quantity must be between 1 and 99")] public int Quantity { get; set; } = 1; }
Name is required
When form is valid and submitted:
Order placed for Jane Smith! Qty: 3
As user types in Notes textarea (live):
Order Notes (183 chars remaining) <- updates on every keystroke
Blazor Server vs Blazor WebAssembly — Choosing the Right Hosting Model
This is the question every Blazor developer faces first and gets wrong most often. Both hosting models use identical component syntax, so the choice is about execution environment and trade-offs, not about learning different APIs.
Blazor Server runs your C# components on the server. When a user clicks a button, a SignalR message travels from the browser to the server, the C# handler runs, the new virtual DOM diff is calculated on the server, and only the minimal HTML patch is sent back to the browser. The browser itself is just a thin rendering surface. This means startup is near-instant (no big download), you can access databases and file systems directly from component code, and your app works on browsers that don't support WebAssembly. The cost is latency sensitivity — every interaction needs a round-trip, and a server outage affects every connected user simultaneously.
Blazor WebAssembly (WASM) downloads the entire .NET runtime and your compiled DLLs to the browser on first load. After that, all component logic runs locally — zero server round-trips for UI interactions, offline capability, and it can be hosted as static files on a CDN. The trade-off is a larger initial download (typically 2-5 MB compressed) and no direct database access from component code.
A third option, Blazor United (now called Blazor Web App with Auto render mode in .NET 8+), starts components as Server for instant interactivity and then seamlessly transitions to WASM once the runtime downloads in the background — giving you the best of both.
@* WeatherDashboard.razor — works identically in both Server and WASM hosting *@ @* The component code is the same; only the Program.cs setup differs *@ @page "/weather" @inject IWeatherService WeatherService @* Injected service — works in both models *@ <h2>Weather Dashboard</h2> @if (isLoading) { <p>Loading weather data...</p> @* Shown during async fetch *@ } else if (forecasts == null || !forecasts.Any()) { <p>No forecast data available.</p> } else { <table class="weather-table"> <thead> <tr><th>Date</th><th>Temp (°C)</th><th>Summary</th></tr> </thead> <tbody> @foreach (var forecast in forecasts) { <tr> <td>@forecast.Date.ToShortDateString()</td> <td class="@GetTempCssClass(forecast.TemperatureC)"> @forecast.TemperatureC </td> <td>@forecast.Summary</td> </tr> } </tbody> </table> } <button @onclick="RefreshForecasts" disabled="@isLoading"> @(isLoading ? "Refreshing..." : "Refresh") </button> @code { private List<WeatherForecast>? forecasts; private bool isLoading = true; // OnInitializedAsync is the correct hook for async data loading // It fires after the first render, preventing the UI from blocking protected override async Task OnInitializedAsync() { await LoadForecastsAsync(); } private async Task RefreshForecasts() { isLoading = true; // StateHasChanged() is NOT needed here — Blazor tracks event handlers automatically await LoadForecastsAsync(); } private async Task LoadForecastsAsync() { isLoading = true; forecasts = await WeatherService.GetForecastAsync(DateTime.Today, days: 5); isLoading = false; // For Blazor Server: the SignalR diff is sent to browser automatically after this // For Blazor WASM: the WASM runtime re-renders locally after this } // Helper to apply a CSS class based on temperature value private string GetTempCssClass(int tempC) => tempC switch { <= 0 => "temp-freezing", <= 15 => "temp-cold", <= 25 => "temp-mild", _ => "temp-hot" }; } // --- WeatherForecast.cs --- public record WeatherForecast(DateOnly Date, int TemperatureC, string Summary);
Loading weather data...
After data loads:
Weather Dashboard
Date Temp(°C) Summary
01/06/2025 22 Mild
02/06/2025 -3 Freezing <- gets CSS class 'temp-freezing'
03/06/2025 28 Hot <- gets CSS class 'temp-hot'
04/06/2025 12 Cold
05/06/2025 19 Mild
[Refresh] button
Dependency Injection and Service Lifetimes in Blazor Components
Blazor is built on top of ASP.NET Core's dependency injection container, so you get the same AddScoped, AddTransient, and AddSingleton lifetimes — but their behaviour in Blazor is subtly different from MVC and it catches people out.
In Blazor Server, a Scoped service lives for the duration of the SignalR circuit (the user's connection), not just a single HTTP request. This means a scoped service is effectively per-user and can hold state across multiple component renders and navigations — useful for shopping carts or user preferences. A Singleton is shared across all users on the server, so be careful with mutable state there.
In Blazor WebAssembly there's only one user per browser tab, so Scoped and Singleton behave identically in practice — they both live for the app's lifetime.
The @inject directive is how you pull services into a component. You can also use the [Inject] attribute in the @code block for constructor-style injection. The critical rule: never do real work in a component's constructor. Always use OnInitializedAsync for anything that involves services, because the DI container has fully resolved all dependencies by the time that lifecycle hook fires.
@* ShoppingCartSidebar.razor *@ @* Demonstrates @inject, a scoped service, and reacting to external state changes *@ @inject ICartService CartService @* Scoped: one instance per SignalR circuit *@ @inject NavigationManager NavManager @* Built-in Blazor service for URL navigation *@ @implements IDisposable @* We subscribe to an event, so we must unsubscribe *@ <div class="cart-sidebar"> <h4>Your Cart (@cartItemCount items)</h4> @foreach (var item in cartItems) { <div class="cart-item"> <span>@item.ProductName</span> <span>x@item.Quantity</span> <button @onclick="() => RemoveItem(item.ProductId)">Remove</button> </div> } <p class="cart-total">Total: @cartTotal.ToString("C")</p> <button @onclick="GoToCheckout" class="btn-checkout">Checkout</button> </div> @code { private List<CartItem> cartItems = new(); // Computed properties — Blazor recalculates these each render, no manual sync needed private int cartItemCount => cartItems.Count; private decimal cartTotal => cartItems.Sum(i => i.LineTotal); protected override async Task OnInitializedAsync() { // Load initial cart state from the service cartItems = await CartService.GetItemsAsync(); // Subscribe to cart change events from other components (e.g., ProductCard) // This allows the sidebar to update when ANY component adds a product CartService.OnCartChanged += HandleCartChanged; } // This handler is called by the service when cart changes from OUTSIDE this component private async void HandleCartChanged() { cartItems = await CartService.GetItemsAsync(); // InvokeAsync ensures StateHasChanged is called on the correct sync context // This is CRITICAL when the callback comes from a background thread or another component await InvokeAsync(StateHasChanged); } private async Task RemoveItem(int productId) { await CartService.RemoveItemAsync(productId); // CartService will fire OnCartChanged, which calls HandleCartChanged above } private void GoToCheckout() { NavManager.NavigateTo("/checkout"); } // IDisposable — always unsubscribe from events to prevent memory leaks public void Dispose() { CartService.OnCartChanged -= HandleCartChanged; } } // --- ICartService.cs --- public interface ICartService { event Action OnCartChanged; Task<List<CartItem>> GetItemsAsync(); Task RemoveItemAsync(int productId); Task AddItemAsync(ProductModel product); } // --- CartItem.cs --- public record CartItem(int ProductId, string ProductName, int Quantity, decimal UnitPrice) { public decimal LineTotal => Quantity * UnitPrice; }
Your Cart (2 items)
-------------------
Mechanical Keyboard x1 [Remove]
USB-C Hub x2 [Remove]
-------------------
Total: $189.97
[Checkout]
After clicking Remove on Mechanical Keyboard:
Your Cart (1 items)
-------------------
USB-C Hub x2 [Remove]
-------------------
Total: $59.98
[Checkout]
| Feature / Aspect | Blazor Server | Blazor WebAssembly |
|---|---|---|
| Where C# code runs | On the server (IIS, Kestrel) | In the browser (WebAssembly runtime) |
| First load time | Near-instant — no DLL download | Slower — downloads ~2–5 MB of .NET runtime |
| UI interaction latency | Network round-trip per event | Zero latency — runs locally |
| Database access | Direct — EF Core works in components | Indirect — must call an HTTP API |
| Offline support | None — requires server connection | Full — runs without a server after load |
| Scalability | Memory per active user on server | Scales to CDN — server-side stateless |
| Debugging | Visual Studio debugger works normally | Browser DevTools + VS WASM debugger |
| Suitable for | Line-of-business internal tools | Public SPAs, PWAs, offline apps |
| SignalR dependency | Required — circuit drops = app freezes | Not required for UI interactions |
| .NET 8 Auto mode | Starts as Server, migrates to WASM | Starts as Server, migrates to WASM |
🎯 Key Takeaways
- A .razor component is a self-contained unit of UI — markup, logic, and events in one file. This is not just convenience, it's the architecture: components compose into trees, data flows down via [Parameter], and events bubble up via EventCallback.
- Two-way binding with @bind is syntactic sugar for wiring up both a value attribute and a change event. Use @bind:event='oninput' when you need keystroke-level reactivity (live search, character counters), and leave it as the default 'onchange' for everything else.
- Blazor Server and Blazor WebAssembly use identical component syntax — the hosting model is a deployment and architecture decision, not a coding decision. The main trade-off is latency vs. server memory: WASM trades initial download size for zero interaction latency; Server trades per-user memory for instant startup.
- Always use InvokeAsync(StateHasChanged) — never plain StateHasChanged() — when triggering a re-render from a callback, timer, or background task. And always implement IDisposable to unsubscribe from events. These two rules eliminate the most common Blazor bugs in production.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Calling StateHasChanged after every await — Symptom: double-renders, flickering UI, and occasionally a threading exception — Fix: Blazor automatically re-renders after any event handler or lifecycle method completes. Only call StateHasChanged explicitly when a state change happens outside of Blazor's control, such as in a Timer callback, a background Task, or a SignalR hub message handler. Everywhere else, delete that call.
- ✕Mistake 2: Fetching data in OnInitialized instead of OnInitializedAsync — Symptom: the component renders with empty data even though the API call succeeded, or you see 'cannot access disposed object' exceptions — Fix: If your data fetch is async (any Task-returning method), you must use the async version: OnInitializedAsync. Using the sync version and calling .Result or .GetAwaiter().GetResult() deadlocks Blazor Server's synchronisation context. Always await async calls inside OnInitializedAsync.
- ✕Mistake 3: Not implementing IDisposable when subscribing to events or timers — Symptom: memory leaks, components that keep responding to events after being navigated away from, or ghost UI updates modifying unmounted components — Fix: Any component that subscribes to a C# event, starts a System.Timers.Timer, or registers a callback with an injected service must implement IDisposable. In the Dispose() method, unsubscribe every event and stop every timer. In .razor files, declare this with '@implements IDisposable' at the top and add a Dispose() method in the @code block.
Interview Questions on This Topic
- QWhat is the difference between Blazor Server and Blazor WebAssembly, and what are the specific production scenarios where you would choose one over the other?
- QExplain how Blazor's component rendering pipeline works — what triggers a re-render, when does StateHasChanged need to be called explicitly, and what is the role of ShouldRender()?
- QIf a Blazor Server app has 10,000 concurrent users, what are the memory and scalability implications of using Scoped services, and how would you architect around that constraint?
Frequently Asked Questions
Do I still need JavaScript when using Blazor?
For the vast majority of interactive UI work — forms, events, state, API calls — you don't need JavaScript at all. You'll only reach for JavaScript interop (via IJSRuntime) when you need browser APIs that .NET doesn't expose natively, like accessing the clipboard, calling a third-party JS library, or measuring DOM element dimensions. Blazor has a clean IJSRuntime.InvokeAsync API for exactly these cases.
Is Blazor WebAssembly slower than Blazor Server for page interactions?
After the initial download, Blazor WebAssembly is actually faster for UI interactions because everything runs locally in the browser — there's no network round-trip per click. Blazor Server is faster to first interactive paint because there's no download, but every subsequent interaction pays a network latency cost. For users on poor connections, Blazor Server's per-click latency can make the app feel sluggish.
Can I use Blazor components inside an existing MVC or Razor Pages app?
Yes, and this is a great migration strategy. ASP.NET Core supports embedding Blazor Server components directly in Razor Pages using the component tag helper: '
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.