Custom Controls & Reusable UI Components in .NET — Web Forms to Blazor
In short: User control (.ascx): created with markup + code-behind, easy to build, reusable only within the same web application (the .ascx file must be present), limited design-time support.
- Custom server control: a compiled class deriving from
Control/WebControl/CompositeControlthat renders its own HTML; - reusable across applications via a DLL, can be added to the Toolbox with designer support, more effort to build.
In short: An extension method on HtmlHelper (or HtmlHelper<TModel>) returning MvcHtmlString/IHtmlContent that generates reusable markup, e.g. @Html.Money(total).
- Build HTML with
TagBuilder(which encodes text), import the namespace inViews/web.config(MVC 5) or_ViewImports.cshtml(Core).
In short: Tag helpers are classes that process HTML elements/attributes in Razor (<input asp-for="Name">, custom <money amount="...">), producing HTML on the server.
- They look like HTML (designer-friendly, attribute IntelliSense) versus HTML helpers which are C# method calls (
@Html.TextBoxFor(...)). - Tag helpers exist only in ASP.NET Core; both can be mixed.
In short: A partial view renders markup with data provided by the caller;
- a view component is a class with its own logic and dependencies (constructor injection, async
InvokeAsync) plus a view — for widgets that fetch their own data (cart summary, navigation menu). - View components replace MVC 5 child actions and are lighter than a full controller action.
In short: Create a .razor file with markup and an @code block;
- expose inputs as
[Parameter]properties, content slots asRenderFragment(ChildContent, templatesRenderFragment<T>), and notify the parent through[Parameter] EventCallback/EventCallback<T>invoked withInvokeAsync, which also triggers the parent's re-render. - For two-way binding expose
Value+ValueChanged.
In short: Store it in ViewState in the property getter/setter (ViewState["Amount"]), which is saved and restored automatically;
- for essential state that must work even with ViewState disabled use ControlState (
RegisterRequiresControlState,SaveControlState/LoadControlState). - Child controls of composite controls must be recreated in
CreateChildControlson every request.
In short: A class library project that contains Razor views, Razor Pages, view components, tag helpers, Blazor components and static web assets, which other ASP.NET Core apps reference (project or NuGet).
- It lets teams share UI (layouts, admin areas, component kits) across applications; static files are served from
_content/{LibraryName}/.
In short: Properties registered with the WPF property system (DependencyProperty.Register) whose values can come from bindings, styles, templates, animations and inheritance, with change notifications and default values.
- Custom controls expose their public properties as dependency properties so consumers can data-bind and style them.
In short: Blazor: bUnit renders components in memory, sets parameters, triggers events and asserts markup/callbacks.
- Tag helpers: unit test
Processwith fakeTagHelperContext/TagHelperOutput. - View components: unit test
InvokeAsyncresults with mocked services. - Web Forms controls: test logic extracted from the control; render tests via
HtmlTextWriter. - End-to-end: Playwright/Selenium.
In short: Control is the base of all server controls — no styling properties; you override Render and write all HTML.
WebControladds common style properties (CssClass, Style, Font, Enabled) and a tag-rendering model (TagKey,AddAttributesToRender,RenderContents).CompositeControlbuilds its UI from child controls inCreateChildControls(reusing TextBoxes, Buttons, validators) and handles naming and child-control lifetime for you.
In short: Expose a [Parameter] public T Value and a [Parameter] public EventCallback<T> ValueChanged (optionally ValueExpression for validation).
- When the component's internal value changes, call
await ValueChanged.InvokeAsync(newValue). - The parent can then write
<MyInput @bind-Value="model.Name" />, which Blazor expands to Value + ValueChanged.
In short: Razor partials placed in Views/Shared/DisplayTemplates or EditorTemplates (or per controller) named after a type (DateTime.cshtml, Money.cshtml) or selected with [UIHint("Money")].
Html.DisplayFor/EditorForautomatically use them, so every property of that type renders consistently across the site — a lightweight way to build reusable UI without code.
In short: Put them in a Razor Class Library — views, partials, view components, tag helpers, Blazor components and static assets — and reference it as a project or publish it as an internal NuGet package with semantic versioning.
- Consumers add
@addTagHelper/@usingin_ViewImports, and static files are available under_content/{LibraryName}/.
In short: In ASP.NET Core: a tag helper (<price amount=".." discount="..">) if it only needs values passed in, or a partial view/display template for richer markup;
- a view component if it must fetch data (e.g. live offers).
- In MVC 5: a custom HTML helper or a display template with
[UIHint]. - Centralising fixes formatting bugs once and keeps pages consistent.
In short: Its child controls aren't recreated early enough or with the same IDs on postback, so ViewState and posted data can't be applied and the event source isn't found.
- Create children in
CreateChildControls(called viaEnsureChildControls) on every request, implementINamingContainer, and add dynamic instances of the control inPage_Init/PreInit, not insideif (!IsPostBack).
Virtualize rows (<Virtualize> component) or page server-side, add @key to row elements, avoid re-rendering the whole grid on every change (ShouldRender, immutable parameters, split into smaller components), avoid heavy work in OnParametersSet, and use QuickGrid or a component library's optimised grid.