A WebForms-to-Blazor control map

When a WebForms slice is rebuilt on Blazor, most of the work is not exotic. It is a long series of small translations: this control becomes that component, this life-cycle event becomes that method, this page-level trick becomes state the component owns. Having the map in your head before you start keeps the rewrite mechanical where it can be mechanical, and saves the thinking for the parts that deserve it.

This is the map we use. It assumes Blazor on .NET 8 with interactive server rendering, which is the closest operational match to how a WebForms app already behaves: state and rendering live on the server, and UI events round-trip to it. Microsoft's free e-book Blazor for ASP.NET Web Forms Developers covers the same ground chapter by chapter and is worth having open beside this.

The control map

WebFormsBlazor equivalentNotes
.aspx pageRoutable component (@page)One page becomes one .razor file with a route template.
Master pageLayout componentInherit LayoutComponentBase; ContentPlaceHolder becomes @Body.
User control (.ascx)Component[Parameter] properties replace the control's public properties.
Code-behind (.aspx.cs)@code block or partial classA partial class keeps the file layout familiar during migration.
GridViewQuickGridThe built-in QuickGrid component covers sorting, paging and templated columns.
Repeater / ListView@foreach over a collectionThe pattern collapses to a loop with markup; no control needed.
TextBox, DropDownList, CheckBoxInputText, InputSelect, InputCheckboxInside an EditForm, bound with @bind-Value.
ValidatorsData annotations plus ValidationMessageRules move off the markup and onto the model.
Page_LoadOnInitializedAsync / OnParametersSetAsyncThere is no IsPostBack; initialization runs per navigation, not per event.
ViewStateComponent fieldsState is ordinary fields on the component for the circuit's lifetime.
SessionScoped servicesDecide deliberately what survives; do not recreate a session grab-bag.
UpdatePanelNothingInteractive server rendering already does partial updates.
Response.RedirectNavigationManager.NavigateToAn injected service, without the ThreadAbortException folklore.

Three of those rows do most of the work in a typical line-of-business app: pages, grids, and forms.

Worked example: a grid page

The WebForms version

A screen most of these systems have somewhere, declared in markup with paging events handled in code-behind:

<asp:GridView ID="gvOrders" runat="server" AutoGenerateColumns="false"
    AllowPaging="true" PageSize="25"
    OnPageIndexChanging="gvOrders_PageIndexChanging">
  <Columns>
    <asp:BoundField DataField="OrderNumber" HeaderText="Order #" />
    <asp:BoundField DataField="CustomerName" HeaderText="Customer" />
    <asp:BoundField DataField="Total" HeaderText="Total" DataFormatString="{0:C}" />
  </Columns>
</asp:GridView>

The code-behind rebinds the grid on every postback, guarded by IsPostBack, with the current page index stored in ViewState.

The Blazor version

@page "/orders"
@inject IOrderQueries Orders

<QuickGrid ItemsProvider="LoadOrders" Pagination="pagination">
  <PropertyColumn Property="@(o => o.OrderNumber)" Title="Order #" Sortable="true" />
  <PropertyColumn Property="@(o => o.CustomerName)" Title="Customer" Sortable="true" />
  <PropertyColumn Property="@(o => o.Total)" Format="C" Title="Total" />
</QuickGrid>
<Paginator State="pagination" />

@code {
    private readonly PaginationState pagination = new() { ItemsPerPage = 25 };

    private async ValueTask<GridItemsProviderResult<OrderRow>> LoadOrders(
        GridItemsProviderRequest<OrderRow> request)
    {
        var page = await Orders.GetPageAsync(request.StartIndex, request.Count ?? 25);
        return GridItemsProviderResult.From(page.Rows, page.TotalCount);
    }
}

The binding events, the IsPostBack guard and the ViewState payload are gone; paging state lives in pagination, and data access is an injected service you can test on its own. This is also the moment to pull the query out of the page. WebForms code-behind usually talks to the database directly, and moving that into IOrderQueries is what makes the next page that needs the same data cheap.

What does not map

  • Page life-cycle tricks. Code that depends on the exact ordering of Init, Load and PreRender across nested controls has no equivalent, because the problem it solved no longer exists. Rewrite the intent, not the mechanism.
  • Third-party control suites. A 2009-era grid or scheduler control has no drop-in twin. Each one is a per-control decision: the vendor's Blazor successor, a built-in component, or plain HTML. Characterize the behavior before swapping anything.
  • HttpContext.Current reached from business logic. Blazor components do not process a request per interaction, so code that grabs the current user or query string from deep inside a class library needs those values passed in. Tedious, and one of the better things the migration forces.

Where to start

Not with the map. Start with characterization tests on the pages you are about to move, then translate with the map, then make the harness agree; we walked through a workable harness in golden-master tests for legacy web apps. The map makes the translation fast. The harness makes it safe.