FindControl in BWFC
June 2, 2026 · View on GitHub
FindControl is a Web Forms method that allows you to locate a control in the page's control tree by its ID. BWFC provides full runtime support for FindControl on BaseWebFormsComponent — your existing code-behind that uses FindControl compiles and runs without modification.
!!! tip "Zero Code Rewrites"
Unlike other migration approaches that rewrite FindControl("Id") calls into @ref fields, BWFC supports FindControl as a first-class runtime method. Cast patterns, chained lookups, and case-insensitive matching all work out of the box.
// This Web Forms code works unchanged in BWFC:
TextBox searchBox = (TextBox)FindControl("SearchBox");
searchBox.Text = "hello";
// Chained lookups work too:
var child = FindControl("Panel1").FindControl("TextBox1");
Live Demo → — See FindControl working in the sample app.
This guide also covers the background of FindControl, naming container boundaries, and idiomatic Blazor alternatives for new code.
What FindControl Does in Web Forms
FindControl(string id) searches the control hierarchy for a control with the specified ID:
// Web Forms Page code-behind
protected void Page_Load(object sender, EventArgs e)
{
TextBox searchBox = (TextBox)FindControl("SearchBox");
if (searchBox != null)
{
searchBox.Text = "Initial value";
}
}
It returns null if the control is not found, which is why code often checks before using the result.
The search is shallow by default — it only searches direct children of the current container. To search deeper, you must either:
- Recursively call FindControl on child containers, or
- Understand naming container boundaries (explained below)
The Naming Container Problem
A naming container is any control that implements INamingContainer. These include:
Page— The top-level containerContentPlaceHolder— Master page content areasPanelwithGroupingText- Custom controls inheriting from
INamingContainer
The Problem: FindControl does not cross naming container boundaries. If a control is inside a naming container that is not a direct ancestor, FindControl cannot find it.
Example: The Master Page Boundary Problem
In DepartmentPortal, the master page contains a MessageLiteral control in the header:
Site.Master:
<%@ Master Language="C#" %>
<html>
<head>
<title>Department Portal</title>
</head>
<body>
<form runat="server">
<div class="header">
<asp:Literal ID="MessageLiteral" runat="server" />
</div>
<asp:ContentPlaceHolder ID="MainContent" runat="server" />
</form>
</body>
</html>
MyPage.aspx (content page trying to access master's control):
protected void Page_Load(object sender, EventArgs e)
{
// This fails! FindControl cannot cross the ContentPlaceHolder boundary
var message = (Literal)FindControl("MessageLiteral");
// Result: null
}
Why does it fail? The ContentPlaceHolder is a naming container. The page's FindControl searches within the page's container and the ContentPlaceHolder, but not the MasterPage's content (which is in a separate naming container managed by the master).
The Web Forms Fix: Access the master page directly:
protected void Page_Load(object sender, EventArgs e)
{
// Cast Master to the specific master page type
var siteMaster = (Site)Master;
siteMaster.SetMessage("Welcome!");
}
And add a public method to the master page code-behind:
// Site.Master.cs
public void SetMessage(string text)
{
MessageLiteral.Text = text;
}
Example: The Template Container Problem
In DepartmentPortal, the SectionPanel control is a composite that uses ITemplate for child content:
SectionPanel.cs (Web Forms custom control):
public class SectionPanel : CompositeControl, INamingContainer
{
protected override void CreateChildControls()
{
var container = new Control();
Controls.Add(container);
if (ContentTemplate != null)
{
ContentTemplate.InstantiateIn(container);
}
}
[TemplateContainer(typeof(SectionPanel))]
public ITemplate ContentTemplate { get; set; }
}
PageContent.aspx (content page with SectionPanel):
<asp:SectionPanel ID="AnnouncementsSection" runat="server">
<ContentTemplate>
<asp:Repeater ID="AnnouncementsRepeater" runat="server" />
</ContentTemplate>
</asp:SectionPanel>
PageContent.aspx.cs (trying to access repeater):
protected void Page_Load(object sender, EventArgs e)
{
// This fails! The Repeater is inside SectionPanel's template container
var repeater = (Repeater)FindControl("AnnouncementsRepeater");
// Result: null
// Correct approach: go through the panel
var panel = (SectionPanel)FindControl("AnnouncementsSection");
var repeater = (Repeater)panel.FindControl("AnnouncementsRepeater");
}
Why? SectionPanel implements INamingContainer, creating a boundary. Controls inside the template are children of the panel's container, not the page.
Why FindControl Doesn't Translate to Blazor
Blazor uses a component-based architecture, not a control tree:
- Components are not automatically indexed — Blazor components don't have a global registry
- Component hierarchy is logical, not traversable — There is no "control tree" API
- Parameters are explicit — Communication happens through parameters and cascading values, not search
In Blazor, the equivalent of "finding a control by ID" is:
- Storing a direct reference via
@ref - Passing data through parameters
- Using cascading parameters for ancestor→descendant communication
- Using events for descendant→ancestor communication
Blazor Equivalents
Pattern 1: @ref for Direct References
Use @ref to store a reference to a component or HTML element:
Before (Web Forms):
TextBox searchBox = (TextBox)FindControl("SearchBox");
searchBox.Text = "Search here";
After (Blazor component):
<SearchBox @ref="searchBoxRef" />
@code {
private SearchBox searchBoxRef;
private void SetSearchText()
{
searchBoxRef.Text = "Search here"; // Requires public property on SearchBox
}
}
Limitation: The child component must expose the property publicly.
Pattern 2: Parameters for Configuration
Instead of finding and modifying a control after creation, pass the desired state as a parameter:
Before (Web Forms — find and configure):
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
TextBox nameBox = (TextBox)FindControl("NameTextBox");
nameBox.Text = currentUser.Name;
}
}
<asp:TextBox ID="NameTextBox" runat="server" />
After (Blazor — pass as parameter):
<NameEditor InitialValue="currentUser.Name" />
@* NameEditor.razor *@
<input type="text" value="@InitialValue" />
@code {
[Parameter]
public string InitialValue { get; set; }
}
Pattern 3: Cascading Parameters for Ancestor Communication
Use cascading parameters to allow deep component hierarchies to access ancestor state:
Before (Web Forms — find in master page):
// Content page code-behind
protected void ShowAlert(string message)
{
var master = (SiteMaster)Master;
master.DisplayAlert(message); // Requires public method on master
}
After (Blazor — cascading parameter):
@* App.razor or MainLayout.razor *@
<CascadingValue Value="this">
@Body
</CascadingValue>
@code {
public void DisplayAlert(string message) { /* ... */ }
}
@* Any descendant component *@
@code {
[CascadingParameter]
public MainLayout Layout { get; set; }
private void ShowAlert(string message)
{
Layout?.DisplayAlert(message);
}
}
Pattern 4: EventCallback for Sibling/Child Communication
Use EventCallback<T> to communicate upward from child to parent:
Before (Web Forms — repeater item command):
protected void EmployeeRepeater_ItemCommand(object source, RepeaterCommandEventArgs e)
{
if (e.CommandName == "Delete")
{
int employeeId = (int)e.CommandArgument;
DeleteEmployee(employeeId);
}
}
After (Blazor — event callback):
@* Parent *@
<EmployeeList OnDeleteRequested="HandleDelete" />
@code {
private async Task HandleDelete(int employeeId)
{
await DeleteEmployee(employeeId);
}
}
@* Child (EmployeeList.razor) *@
<button @onclick="() => OnDeleteRequested.InvokeAsync(employeeId)">Delete</button>
@code {
[Parameter]
public EventCallback<int> OnDeleteRequested { get; set; }
}
Pattern 5: Dependency Injection for Cross-Cutting Concerns
For global services (authentication, logging, settings), use DI instead of searching:
Before (Web Forms — find global master control):
var userLabel = (Label)FindControl("UserLabel"); // Unreliable
userLabel.Text = GetCurrentUserName();
After (Blazor — inject service):
@inject AuthService Auth
<span>Welcome, @Auth.CurrentUser.Name</span>
@code {
protected override async Task OnInitializedAsync()
{
await Auth.LoadUserAsync();
}
}
BWFC's FindControl — Runtime Contract
BaseWebFormsComponent provides a hardened FindControl implementation as a formal runtime contract:
public abstract class BaseWebFormsComponent : ComponentBase, IAsyncDisposable
{
public virtual BaseWebFormsComponent FindControl(string controlId)
{
// O(1) dictionary lookup for direct children
// Recursive descent for nested controls
// Case-insensitive matching
}
}
Key features:
- O(1) direct-child lookup — Children are indexed by ID in a case-insensitive dictionary
- Recursive descent — Nested controls are found by traversing the component tree
- Cast-compatible — Returns
BaseWebFormsComponent, castable toTextBox,Panel, etc. - Chaining —
FindControl("A").FindControl("B")works because every component has the method - Auto-registration — Children register with their parent on initialization
- Auto-unregistration — Children unregister on disposal (conditional rendering safe)
- Lifecycle hook —
OnControlTreeReadyAsync()fires after children are registered
Supported Patterns
// Direct lookup with cast
TextBox box = (TextBox)panel.FindControl("SearchBox");
// Chained container traversal
var nested = FindControl("Panel1").FindControl("TextBox1");
// Null-safe pattern (same as Web Forms)
var control = FindControl("MaybeExists");
if (control != null) { /* use it */ }
// Case-insensitive (Web Forms behavior)
var found = FindControl("searchbox"); // finds ID="SearchBox"
GridViewRow.FindControl — Data Row Controls
For data controls like GridView, FindControl also works on individual rows. GridViewRow<T> and the non-generic GridViewRow shim both support FindControl(string id):
// This Web Forms pattern compiles and works unchanged in BWFC:
GridViewRow row = CartList.Rows[i]; // implicit operator from GridViewRow<T>
TextBox qty = (TextBox)row.FindControl("PurchaseQuantity");
CheckBox remove = (CheckBox)row.FindControl("chkRemove");
In SSR mode, FindControl returns proxy TextBox and CheckBox instances whose Text and Checked properties are populated from the form POST data. This means migrated code that reads control values inside a postback handler works without modification.
Key compatibility features:
| Feature | Description |
|---|---|
Rows[i] typed indexer | Returns GridViewRow<T>, not IRow<T> |
| Implicit conversion | GridViewRow<T> converts to non-generic GridViewRow automatically |
FindControl on rows | Returns proxy controls populated from form POST data |
Cells collection | DataControlFieldCellCollection with ContainingField.ExtractValuesFromCell() |
RowState | DataControlRowState flags enum (Normal, Alternate, Edit, Selected) |
See GridView — GridViewRow Compatibility for full details and code examples.
Complete DepartmentPortal Migration Examples
Example 1: Master Page Message Control
Original Web Forms:
// Site.Master.cs
public void SetMessage(string message)
{
MessageLiteral.Text = message;
}
// MyPage.aspx.cs
protected void Page_Load(object sender, EventArgs e)
{
((Site)Master).SetMessage("Welcome!");
}
Blazor Equivalent:
@* MainLayout.razor *@
<CascadingValue Value="this">
@Body
</CascadingValue>
<div class="message">@Message</div>
@code {
public string Message { get; set; }
public void SetMessage(string message)
{
Message = message;
StateHasChanged(); // Trigger re-render
}
}
@* MyPage.razor *@
@page "/"
@inject MainLayout Layout
<h1>Welcome</h1>
@code {
protected override async Task OnInitializedAsync()
{
Layout.SetMessage("Welcome!");
}
}
Example 2: SectionPanel with Repeater
Original Web Forms:
<asp:SectionPanel ID="AnnouncementsSection" runat="server">
<ContentTemplate>
<asp:Repeater ID="AnnouncementsRepeater" runat="server" />
</ContentTemplate>
</asp:SectionPanel>
protected void Page_Load(object sender, EventArgs e)
{
var panel = (SectionPanel)FindControl("AnnouncementsSection");
var repeater = (Repeater)panel.FindControl("AnnouncementsRepeater");
repeater.DataSource = GetAnnouncements();
repeater.DataBind();
}
Blazor Equivalent:
<SectionPanel @ref="announcementsPanelRef">
<Repeater Items="announcements">
<ItemTemplate>
<div>@context.Title</div>
</ItemTemplate>
</Repeater>
</SectionPanel>
@code {
private SectionPanel announcementsPanelRef;
private List<Announcement> announcements = new();
protected override async Task OnInitializedAsync()
{
announcements = await GetAnnouncements();
}
}
Key difference: The repeater is bound declaratively via the Items parameter, not through imperative FindControl + DataBind.
Example 3: Navigation Control with Active State
Original Web Forms:
protected void Page_Load(object sender, EventArgs e)
{
var navControl = (SidebarNav)FindControl("Navigation");
if (navControl != null)
{
navControl.SetActiveItem(GetCurrentPageName());
}
}
Blazor Equivalent:
<SidebarNav ActiveItem="currentPageName" />
@code {
private string currentPageName;
protected override void OnInitialized()
{
currentPageName = GetCurrentPageName();
}
}
Pattern: Pass the active item as a parameter instead of finding and calling a method.
Migration Patterns Table
| Web Forms Pattern | Problem | Blazor Solution |
|---|---|---|
FindControl("ID") on direct child | Simple lookup | Use @ref reference |
FindControl() for configuration | Late-binding state | Use parameters instead |
| Access control in master page | Naming container boundary | Expose public method on master; call from derived page class |
| Access control in content placeholder | Naming container boundary | Pass as cascading parameter from master |
| Search repeater items | Dynamic control creation | Use @foreach with direct references |
| Get control value to process | Imperative access | Use two-way binding @bind or parameters |
| Fire child control event from parent | Cross-component signaling | Use @ref to call public method, or use event callbacks |
| Access sibling controls | Lateral traversal | Use parent as intermediary; communicate via parameters/events |
Common Pitfalls
Pitfall 1: Assuming @ref Works Like FindControl
@ref only works for components and HTML elements in the current component's template. It does not recursively search child components.
@* Wrong — SearchBox is not a direct child *@
<Container>
<SearchBox @ref="searchRef" /> @* Won't work *@
</Container>
@* Correct — hold reference to Container, not SearchBox *@
<Container @ref="containerRef" />
@code {
private Container containerRef;
private SearchBox GetSearchBox() => containerRef.SearchBoxRef; @* Requires Container to expose it *@
}
Pitfall 2: Forgetting to Check for Null
FindControl returns null if not found. Blazor's @ref is type-safe, but you must still null-check:
@code {
private SearchBox searchRef;
private void DoSomething()
{
if (searchRef != null)
{
searchRef.Focus();
}
}
}
Pitfall 3: Modifying Control State After FindControl
FindControl returns a control you can modify, but in Blazor, parameters are one-way. Modifying a component via @ref bypasses the parameter binding and can cause inconsistency:
@* Problematic *@
<TextBox @ref="textRef" Value="initialValue" />
@code {
private TextBox textRef;
private void BadApproach()
{
textRef.Value = "new value"; @* Bypasses the Value parameter binding *@
}
private void GoodApproach()
{
// Instead, change state in parent and let it flow down
initialValue = "new value";
StateHasChanged(); @* Trigger re-render with new Value parameter *@
}
}
See Also
- User Controls Migration Guide — Full guide on migrating ASCX controls
- Cascading Parameters and Values — Microsoft docs on cascading parameters
- Component References with @ref — Official Blazor documentation
- Custom Controls Migration Guide — Information on BWFC's BaseWebFormsComponent