Server Controls and Event Handling
Before you start
You need: the page lifecycle (Article 02).
Time: about 40 minutes, plus the practice.
Learning objective
Find the handler for any control on a Web Forms page, including handlers that no name search will locate, and explain when each event fires.
Topics
- HTML controls versus Web server controls
- The controls you will meet most
- Event wiring:
Handles,AutoEventWireup,AddHandler AutoPostBackand when a control causes a round tripsender,EventArgs, and command events- Control ids, naming containers, and
ClientID FindControl
Two families of server control
<!-- HTML server control — a plain HTML element with runat="server" -->
<input type="text" id="txtCode" runat="server" />
<!-- Web server control — an ASP.NET object that renders its own HTML -->
<asp:TextBox ID="txtCode" runat="server" MaxLength="20" />
| HTML server control | Web server control | |
|---|---|---|
| Syntax | <input runat="server"> | <asp:TextBox> |
| Value property | .Value | .Text |
| Rendered output | What you wrote | Generated |
| Rich behaviour | No | Yes — validation, binding, events |
| Browser adaptation | No | Yes |
Legacy applications mix both. The property name is the practical difference: reading .Text on an HTML control does not compile, and reading .Value on a TextBox does not either. When a property is not found, check which family the control belongs to.
Controls you will meet
<asp:Label ID="lblStatus" runat="server" Text="" />
<asp:TextBox ID="txtSearch" runat="server" MaxLength="50" />
<asp:Button ID="btnSearch" runat="server" Text="Search" />
<asp:LinkButton ID="lnkClear" runat="server" Text="Clear" />
<asp:DropDownList ID="ddlClass" runat="server" AutoPostBack="true" />
<asp:CheckBox ID="chkActive" runat="server" Text="Active only" />
<asp:RadioButtonList ID="rblSection" runat="server" RepeatDirection="Horizontal" />
<asp:HiddenField ID="hdnStudentId" runat="server" />
<asp:Panel ID="pnlResults" runat="server" Visible="false" />
<asp:Literal ID="litMessage" runat="server" Mode="Encode" />
<asp:GridView ID="grdStudents" runat="server" AutoGenerateColumns="False" />
Two of these carry a security note.
Label versus Literal. A Label renders a <span> wrapper; a Literal renders nothing but its content. Literal defaults to Mode="PassThrough", which emits raw HTML — assigning user input to it is a cross-site scripting hole. Set Mode="Encode" explicitly, or use a Label, which encodes by default when you assign to .Text.
HiddenField. The value goes to the browser and comes back. It is not secret and it is not trustworthy — a user can change it. Never store a SchoolId or a price there and use it without re-checking server-side.
Button versus LinkButton. A Button renders <input type="submit"> and posts normally. A LinkButton renders an <a> with a JavaScript __doPostBack call — so it does nothing when scripting is disabled, and it cannot be opened in a new tab.
Event wiring — three ways
This is where time is lost on legacy pages, because all three appear in the same application.
1. Handles clause
Protected WithEvents btnSearch As Button
Protected Sub btnSearch_Click(sender As Object, e As EventArgs) Handles btnSearch.Click
LoadStudents(txtSearch.Text)
End Sub
The field must be declared WithEvents — the designer file does this for you. Handles binds by the field name. Renaming the method is safe; renaming the control breaks every Handles clause pointing at it.
One handler can serve several events:
Protected Sub Filter_Changed(sender As Object, e As EventArgs) _
Handles ddlClass.SelectedIndexChanged, ddlSection.SelectedIndexChanged, chkActive.CheckedChanged
LoadStudents()
End Sub
2. AutoEventWireup
<%@ Page AutoEventWireup="true" ... %>
// No Handles clause, no AddHandler — wired purely by method name
protected void Page_Load(object sender, EventArgs e) { }
Only page-level events (Page_Load, Page_Init, Page_PreRender) are wired this way, and only when AutoEventWireup="true". C# projects usually have it on; VB projects usually use Handles.
The failure mode: with AutoEventWireup="false" and no Handles Me.Load, a method named Page_Load simply never runs. There is no error. The page loads blank and everything looks correct in the code.
3. AddHandler in markup or code
<asp:Button ID="btnSave" runat="server" OnClick="btnSave_Click" Text="Save" />
AddHandler btnApprove.Click, AddressOf Approve_Click
The markup OnClick attribute is the C# convention and appears in VB projects too. The code form is used for dynamic controls.
This is the one that costs you time. A handler wired with AddHandler has no Handles clause, so searching the code-behind for the control name finds only the declaration. When you cannot find what responds to a control:
Find in Files (Ctrl+Shift+F), Look in: Entire Solution:
| Search for | File types | Finds |
|---|---|---|
AddHandler | *.vb;*.cs | Handlers wired in code rather than by Handles |
AddressOf | *.vb | The method being wired up |
OnClick= | *.aspx;*.ascx | Handlers wired in the markup instead |
Check the markup as well as the code-behind. A button can be wired three ways — a Handles clause, an OnClick attribute in the .aspx, or an AddHandler call at run time — and a control that appears to do nothing usually has its handler in whichever place you did not look.
AutoPostBack
Most controls do not cause a postback when the user changes them. Their event fires later, batched into the next postback.
<!-- No AutoPostBack — SelectedIndexChanged fires on the NEXT postback -->
<asp:DropDownList ID="ddlClass" runat="server" />
<!-- AutoPostBack — changing it immediately posts the page -->
<asp:DropDownList ID="ddlClass" runat="server" AutoPostBack="true" />
Without AutoPostBack, selecting a class does nothing visible. The user selects, then clicks Search, and then both SelectedIndexChanged and btnSearch_Click fire in the same request — change events run before the click event.
That ordering matters. A dependent dropdown ("choose class, then section fills") needs AutoPostBack="true" on the first one, or the second never populates until something else posts.
AutoPostBack renders an onchange attribute calling __doPostBack. Every change is a full page round trip, so on a form with six auto-posting controls the page feels slow — a real complaint on legacy screens, and the reason UpdatePanel exists.
sender and EventArgs
Protected Sub Approve_Click(sender As Object, e As EventArgs)
Dim button As Button = DirectCast(sender, Button)
Dim studentId As Integer = CInt(button.CommandArgument)
ApproveStudent(studentId)
End Sub
sender is the control that raised the event, which lets one handler serve many controls. Use DirectCast when you are certain of the type and TryCast when you are not.
Command events carry more:
<asp:Button ID="btnRow" runat="server"
CommandName="Approve"
CommandArgument='<%# Eval("Id") %>'
Text="Approve" />
Protected Sub Row_Command(sender As Object, e As CommandEventArgs)
Select Case e.CommandName
Case "Approve"
ApproveStudent(CInt(e.CommandArgument))
Case "Reject"
RejectStudent(CInt(e.CommandArgument))
End Select
End Sub
CommandName and CommandArgument are how buttons inside a GridView row identify which row was clicked. CommandArgument arrives as a string from the browser and is user-modifiable — validate it, and re-check that the row belongs to the current user's school before acting on it.
Control ids and naming containers
A control's rendered id is not the id you wrote. Master pages, user controls, and data-bound rows are naming containers, and each one prefixes the ids inside it.
<!-- Written -->
<asp:TextBox ID="txtSearch" runat="server" />
<!-- Rendered inside a master page -->
<input name="ctl00$MainContent$txtSearch" id="MainContent_txtSearch" type="text" />
<!-- Rendered inside GridView row 3 -->
<input name="ctl00$MainContent$grdStudents$ctl05$txtMarks"
id="MainContent_grdStudents_ctl05_txtMarks" type="text" />
Never hard-code a rendered id in JavaScript or CSS. Use ClientID:
var box = document.getElementById('<%= txtSearch.ClientID %>');
Or make the id predictable:
<asp:TextBox ID="txtSearch" runat="server" ClientIDMode="Static" />
ClientIDMode="Static" keeps exactly the id you wrote — but do not use it inside a repeated row, because every row would then share one id.
When a JavaScript selector stops working after a page is moved into a master page, this is why.
FindControl
To reach a control inside a naming container from code:
Protected Sub grdStudents_RowDataBound(sender As Object, e As GridViewRowEventArgs) _
Handles grdStudents.RowDataBound
If e.Row.RowType <> DataControlRowType.DataRow Then
Return
End If
Dim marksBox As TextBox = TryCast(e.Row.FindControl("txtMarks"), TextBox)
If marksBox Is Nothing Then
Return
End If
marksBox.Text = DataBinder.Eval(e.Row.DataItem, "MarksObtained").ToString()
End Sub
FindControl searches one level within the given container. It does not recurse. Calling Page.FindControl("txtMarks") for a control inside a grid row returns Nothing, which is the usual cause of a null-reference in RowDataBound.
Always TryCast and test for Nothing. FindControl returns Nothing for a wrong name with no error, and the resulting crash points at the line after, not at the typo.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| Button does nothing | Handler wired three possible ways — check all three | Handles, OnClick= in markup, or AddHandler |
| Handler runs twice | Wired in markup and with Handles or AddHandler | Remove one |
Control 'x' of type 'y' must be placed inside a form tag with runat=server | Control outside the <form runat="server"> | Move it inside |
txtName is Nothing in code-behind | The .designer file is stale | Open in Design view to regenerate |
FindControl returns Nothing for a control inside a GridView | It is in a naming container | Search from the row, not the page |
A TextBox change event never fires | AutoPostBack is False | Set AutoPostBack="True", or wait for the next postback |
A control that appears dead usually has its handler somewhere you did not look. Search the markup, the code-behind and for AddHandler before concluding it is unwired.
Common mistakes
- Reading
.Texton an HTML server control, or.Valueon a Web server control - Assigning user input to a
LiteralinPassThroughmode — an XSS hole - Trusting a
HiddenFieldvalue without re-checking it server-side - Assuming a control with no
Handlesclause has no handler AutoEventWireup="false"with noHandles Me.Load, soPage_Loadsilently never runs- Expecting
SelectedIndexChangedto fire withoutAutoPostBack="true" - Hard-coding a rendered id in JavaScript
ClientIDMode="Static"on a control inside a repeated row- Using
FindControlfrom the wrong container and gettingNothing - Trusting
CommandArgumentwithout validating it
Practice
On an existing Web Forms page, inventory every server control: its type, how its event is wired, and whether AutoPostBack is set. Find at least one handler wired by something other than Handles.
Then work the course debugging exercise — find why an event does not fire. Reproduce all four causes and record the symptom of each:
- Remove
runat="server"from a button. - Set
AutoEventWireup="false"and delete theHandles Me.Loadclause. - Remove
AutoPostBack="true"from a dropdown a dependent control relies on. - Rename a control field that has a
Handlesclause pointing at it.
Finally, view source on a page inside a master page and compare a written control id with its rendered id and name attributes.
You can now
- Find the handler for any control, however it was wired
- Predict whether a control causes a postback
- Use
AutoPostBackdeliberately rather than by accident - Reach controls inside a naming container safely
- Search a solution with Find in Files to map event wiring
Review questions
- What are the three ways a Web Forms event handler can be wired, and which is hardest to find?
- What happens with
AutoEventWireup="false"and noHandles Me.Load? - Why does a dropdown's
SelectedIndexChangedsometimes not fire until the user clicks a button? - Why does
Page.FindControlreturnNothingfor a control inside aGridViewrow?
Next: State management