ClientScript Migration Guide
April 7, 2026 ยท View on GitHub
When migrating from ASP.NET Web Forms to Blazor, one of the most critical patterns to understand is JavaScript execution. In Web Forms, Page.ClientScript and ScriptManager manage all client-side scripts. In Blazor, this responsibility falls to IJSRuntime and component lifecycle events.
This guide covers the major ClientScript patterns, why they differ in Blazor, and how to migrate each one.
๐ฏ Recommended: ClientScriptShim (Zero-Rewrite Path)
The easiest path for most migrations is the ClientScriptShim โ a compatibility layer that provides the same API as Page.ClientScript but runs on Blazor's IJSRuntime internally.
What It Is
ClientScriptShim is a scoped Blazor service included in BWFC that:
- Accepts the same method calls as Web Forms'
Page.ClientScript - Requires zero code rewrites โ your existing
RegisterStartupScript(),RegisterClientScriptBlock(), etc. calls work unchanged - Queues scripts during component initialization
- Auto-flushes in
OnAfterRenderAsyncviaIJSRuntime - Handles deduplication by type+key (same behavior as Web Forms)
Automatic Registration
When you call AddBlazorWebFormsComponents() in your Startup, ClientScriptShim is registered as a scoped service and ready to use.
How to Use It
For components inheriting BaseWebFormsComponent:
The ClientScript property is automatically available โ use it exactly as you would in Web Forms:
protected override void OnInitialized()
{
ClientScript.RegisterStartupScript(GetType(), "init",
"alert('Page loaded!');", true);
}
For any other component:
Inject ClientScriptShim and use it the same way:
@inject ClientScriptShim ClientScript
@code {
protected override void OnInitialized()
{
ClientScript.RegisterStartupScript(GetType(), "init",
"alert('Page loaded!');", true);
}
}
Supported Methods
| Method | Status | Notes |
|---|---|---|
RegisterStartupScript(Type, string, string, bool) | โ Supported | Executes in OnAfterRenderAsync |
RegisterStartupScript(Type, string, string) | โ Supported | addScriptTags defaults to false |
RegisterClientScriptBlock(Type, string, string, bool) | โ Supported | Executes before startup scripts |
RegisterClientScriptBlock(Type, string, string) | โ Supported | addScriptTags defaults to false |
RegisterClientScriptInclude(string, string) | โ Supported | Dynamically appends <script> tag |
RegisterClientScriptInclude(Type, string, string) | โ Supported | Type parameter ignored (for compatibility) |
IsStartupScriptRegistered(Type, string) | โ Supported | Deduplication check |
IsClientScriptBlockRegistered(Type, string) | โ Supported | Deduplication check |
IsClientScriptIncludeRegistered(string) | โ Supported | Deduplication check |
GetPostBackEventReference(...) | โ Supported (Phase 2) | Returns __doPostBack() string; handled by postback shim |
GetPostBackClientHyperlink(...) | โ Supported (Phase 2) | Returns hyperlink-compatible postback string |
GetCallbackEventReference(...) | โ Supported (Phase 2) | Returns callback bridge string; requires JS handler |
How It Works Internally
- When you call
RegisterStartupScript(), the script is queued in memory (same deduplication as Web Forms) - During
OnAfterRenderAsync,BaseWebFormsComponentcallsClientScript.FlushAsync(IJSRuntime) - Scripts execute in order: script blocks first, then startup scripts, then includes
- The queue clears after each flush cycle
Before/After Example
Web Forms code-behind:
protected void Page_Load(object sender, EventArgs e)
{
Page.ClientScript.RegisterStartupScript(GetType(), "init",
"console.log('Page loaded');", true);
}
Blazor code-behind (with ClientScriptShim โ changes circled in red):
protected override void OnInitialized()
{
ClientScript.RegisterStartupScript(GetType(), "init",
"console.log('Page loaded');", true);
}
The ClientScript call is identical. Only the lifecycle method name changed.
Strangler Fig Pattern Context
ClientScript migration fits within the broader Strangler Fig migration pattern โ the overarching strategy for incrementally moving from Web Forms to Blazor while keeping both systems running side-by-side.
In the Strangler Fig approach:
- You migrate one page or feature at a time, not all at once
- The legacy Web Forms app continues running in parallel
- Traffic gradually shifts from Web Forms to Blazor
- ClientScriptShim enables this by letting your JavaScript patterns work unchanged during migration
This means you don't need to decide "now or never" on JavaScript refactoring. Use ClientScriptShim to get your migration done quickly (Phase 1), then optionally modernize to IJSRuntime or JS modules later when it makes sense (Phase 2).
For a detailed overview of how ClientScript fits into the incremental migration journey, see the Strangler Fig Pattern guide.
Migration Approaches: A Comparison
Choose the approach that fits your migration timeline:
| Approach | Code Changes | Effort | When to Use |
|---|---|---|---|
| ClientScriptShim (recommended) | None โ keep existing calls | โญ Minimal | Default for most migrations; fastest path to working code |
| Manual IJSRuntime rewrite | Rewrite to IJSRuntime.InvokeVoidAsync() | โญโญ Moderate | When you want to modernize fully and leverage Blazor patterns |
| JS Module pattern | Extract to ES modules, use IJSObjectReference | โญโญโญ Full modernization | New code or heavy JavaScript interaction; long-term maintainability |
๐ Bottom line: Use ClientScriptShim for the first pass to get your migration done quickly. Refactor to modern patterns in Phase 2 if desired.
Overview: Why ClientScript Patterns Differ
What ClientScript Does in Web Forms
In Web Forms, Page.ClientScript (also called ClientScriptManager) enables server-side code to:
- Register startup scripts that run when the page loads
- Include external script files
- Generate postback event references (dynamic
__doPostBackcalls) - Manage script deduplication and versioning
Web Forms assumed:
- Server-side postback lifecycle (
IsPostBack) - Automatic script injection into the rendered HTML
- Browser-managed form submission via
__doPostBack()
What Blazor Offers Instead
In Blazor, JavaScript interop is explicit, component-scoped, and lifecycle-aware:
IJSRuntime.InvokeVoidAsync()/InvokeAsync<T>()for calling JavaScript from C#- Component lifecycle hooks (
OnInitializedAsync,OnAfterRenderAsync) for timing - No postback model โ events are direct component method calls
- Prerendering considerations (server-side rendering without browser interactivity)
This is fundamentally more explicit โ you must choose when to run JavaScript and where it lives (HTML, JavaScript file, or inline in C#). This explicitness is actually safer and easier to reason about than implicit ClientScript injection.
Quick Reference: ClientScript Patterns โ Blazor Equivalents
| Web Forms Pattern | Blazor Equivalent | Difficulty |
|---|---|---|
RegisterStartupScript() with inline script | OnAfterRenderAsync(firstRender) + IJSRuntime.InvokeVoidAsync() | โญ Easy |
RegisterClientScriptInclude() | <script src=""> in layout or JS module import | โญ Easy |
RegisterClientScriptBlock() | Inline <script> in component or JS module | โญ Easy |
GetPostBackEventReference() | @onclick or EventCallback<T> | โญโญ Medium |
Form validation with Page.IsValid | EditContext + DataAnnotationsValidator | โญโญ Medium |
IPostBackEventHandler implementation | EventCallback<T> parameter | โญโญ Medium |
ScriptManager.SetFocus() | @ref element + JS.InvokeVoidAsync("focus", ref) | โญโญ Medium |
ScriptManager.RegisterAsyncPostBackControl() | Remove (Blazor uses component binding) | โญโญโญ Complex |
Dynamic form submission with __doPostBack() | Rewrite as component method calls | โญโญโญ Complex |
1. Startup Scripts โ The Most Common Pattern
What It Does
RegisterStartupScript() runs a block of JavaScript after the page fully loads. Used for initialization: theme application, jQuery plugins, validation setup, etc.
๐ฏ Easiest Approach: ClientScriptShim
For a zero-change migration, use the ClientScriptShim:
protected override void OnInitialized()
{
// No code change from Web Forms!
ClientScript.RegisterStartupScript(GetType(), "InitializeTheme",
"applyTheme('dark');", true);
}
See ClientScriptShim (Zero-Rewrite Path) above for full details.
Alternative Approach: Modern IJSRuntime Rewrite
If you prefer to modernize, use OnAfterRenderAsync() + IJSRuntime. This follows current Blazor best practices but requires code changes.
Web Forms
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
Page.ClientScript.RegisterStartupScript(
type: this.GetType(),
key: "InitializeTheme",
script: "$(function() { applyTheme('dark'); });",
addScriptTags: true);
}
}
The if (!IsPostBack) guard ensures the script runs only on first load, not on postbacks.
Blazor Equivalent
In Blazor, the equivalent is OnAfterRenderAsync(bool firstRender), which fires after the component renders and the DOM is available.
@inject IJSRuntime JS
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Script runs only on first render, like !IsPostBack
await JS.InvokeVoidAsync("eval", "applyTheme('dark');");
// Or better: define function in JavaScript and call it
// await JS.InvokeVoidAsync("initializeTheme");
}
}
}
Key Differences
| Aspect | Web Forms | Blazor |
|---|---|---|
| Guard | if (!IsPostBack) | if (firstRender) |
| Hook | Page load (automatic) | OnAfterRenderAsync (explicit) |
| Script location | Injected by server | External file or JS module |
| Timing | After <body> closes | After component DOM renders |
Best Practice: Use JavaScript Modules (If Modernizing)
For a modern Blazor approach that's cleaner long-term, define a JavaScript function in a module and call it. This is optional if you're using ClientScriptShim initially.
Rather than eval(), define a JavaScript function in a module and call it:
=== "JavaScript (app.js)"
javascript export function initializeTheme() { const theme = localStorage.getItem('theme') || 'light'; document.documentElement.setAttribute('data-theme', theme); }
=== "Blazor Component" ```razor @inject IJSRuntime JS
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Load the module and call the function
var module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./app.js");
await module.InvokeVoidAsync("initializeTheme");
}
}
}
```
This is cleaner, type-safe (intellisense works), and easier to test.
2. Script Includes โ External JavaScript Files
What It Does
RegisterClientScriptInclude() references an external .js file, ensuring it loads before dependent scripts.
๐ฏ Easiest Approach: ClientScriptShim
For a zero-change migration, use the ClientScriptShim:
protected override void OnInitialized()
{
// No code change from Web Forms!
ClientScript.RegisterClientScriptInclude(
"jquery-ui",
"lib/jquery-ui/jquery-ui.min.js");
}
The shim dynamically appends <script> tags via IJSRuntime in OnAfterRenderAsync.
Alternative Approaches
Approach 1: Static <script> tags in layout (Recommended for Always-Needed Scripts)
<!-- Pages/_Layout.html or index.html -->
<!DOCTYPE html>
<html>
<head>
<script src="_framework/lib/jquery/jquery.min.js"></script>
<script src="_framework/lib/jquery-ui/jquery-ui.min.js"></script>
<script src="app.js"></script>
</head>
<body>
<!-- Blazor app content -->
</body>
</html>
Option 2: Dynamic import via IJSRuntime (For Conditional Loads)
If the script is only needed conditionally (e.g., admin users only):
=== "TypeScript/JavaScript"
javascript export async function loadAdminTools() { // Dynamically import the admin module const adminModule = await import('./admin-tools.js'); adminModule.init(); }
=== "Blazor Component" ```razor @inject IJSRuntime JS @inject AuthenticationStateProvider Auth
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var authState = await Auth.GetAuthenticationStateAsync();
if (authState.User.IsInRole("Admin"))
{
var module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./app.js");
await module.InvokeVoidAsync("loadAdminTools");
}
}
}
}
```
Key Differences
| Aspect | Web Forms | Blazor |
|---|---|---|
| Inclusion | Server-side RegisterClientScriptInclude() | HTML <script> tags or JS import() |
| Path | ResolveUrl("~/...") | Web root paths (no ~ needed) |
| Conditional | Check in C# code | Check in component logic |
| Timing | Before </body> | Before component loads or on-demand |
3. Inline Script Blocks
What It Does
RegisterClientScriptBlock() injects inline JavaScript code directly into the page, often for utility functions or event handlers.
๐ฏ Easiest Approach: ClientScriptShim
For a zero-change migration, use the ClientScriptShim:
protected override void OnInitialized()
{
string script = @"
function togglePanel(id) {
var panel = document.getElementById(id);
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
";
// No code change from Web Forms!
ClientScript.RegisterClientScriptBlock(
this.GetType(),
"TogglePanelScript",
script,
addScriptTags: true);
}
Alternative Approaches
Approach 1: JavaScript Module (Recommended for Maintainability)
=== "JavaScript (utils.js)"
javascript export function togglePanel(id) { const panel = document.getElementById(id); panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; }
=== "Blazor Component" ```razor @inject IJSRuntime JS
<button @onclick="() => TogglePanel('myPanel')">Toggle</button>
<div id="myPanel">Content</div>
@code {
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./utils.js");
}
}
private async Task TogglePanel(string id)
{
if (module is not null)
{
await module.InvokeVoidAsync("togglePanel", id);
}
}
}
```
Alternatively: Inline Script in Layout
For truly global scripts, you can inline them in index.html or the layout:
<script>
function togglePanel(id) {
const panel = document.getElementById(id);
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
</script>
But avoid this pattern โ it pollutes the global namespace and makes testing harder. Prefer JavaScript modules.
4. Postback Event References
What It Does
GetPostBackEventReference() generates a dynamic JavaScript call to trigger a postback event, often used in client-side event handlers that need to notify the server. Phase 2 now includes a working shim for this pattern.
๐ฏ Easiest Approach: ClientScriptShim (Phase 2 โ Zero Rewrite)
Web Forms:
public string GetDeleteButtonScript()
{
// Generate: javascript:__doPostBack('btnDelete','clicked')
return Page.ClientScript.GetPostBackEventReference(
new PostBackOptions(btnDelete, "clicked")
{
PerformValidation = false
});
}
// Usage in markup:
// <a href='<%# GetDeleteButtonScript() %>'>Delete</a>
Blazor with BWFC โ Zero rewrite!
// Same code works! ClientScriptShim returns a working __doPostBack() JS string
public string GetDeleteButtonScript()
{
return ClientScript.GetPostBackEventReference(
new PostBackOptions(btnDelete, "clicked")
{
PerformValidation = false
});
}
// Usage in markup:
// <a href="@GetDeleteButtonScript()">Delete</a>
How It Works (Phase 2)
-
GetPostBackEventReference()returns__doPostBack('controlId', 'arg')โ the exact same function name as Web Forms. -
BWFC ships
bwfc-postback.jswhich defines__doPostBack()as a JavaScript bridge function:window.__doPostBack = async function(eventTarget, eventArgument) { // Bridge back into Blazor via JS interop await DotNet.invokeMethodAsync('BlazorWebFormsComponents', 'HandlePostBackFromJs', eventTarget, eventArgument); }; -
The page registers itself as a postback target in
OnAfterRenderAsync, exposing a .NET callback method viaDotNetObjectReference. -
When JavaScript calls
__doPostBack(), it invokes the .NETHandlePostBackFromJsmethod via JS interop, which fires the page'sPostBackevent. -
Your C# code handles the PostBack event, just like Web Forms:
@code { protected override void OnInitialized() { PostBack += (sender, args) => { // args.EventTarget โ the control that triggered the postback // args.EventArgument โ the argument passed HandleMyPostBack(args.EventTarget, args.EventArgument); }; } private void HandleMyPostBack(string eventTarget, string eventArgument) { if (eventTarget == "btnDelete" && eventArgument == "clicked") { DeleteItem(); } } }
Usage Pattern
Use GetPostBackEventReference() in data-bound attributes or JavaScript event handlers that need to trigger server-side actions:
@foreach (var item in items)
{
<a href="@ClientScript.GetPostBackEventReference(item, "edit")">
Edit
</a>
<a href="@ClientScript.GetPostBackEventReference(item, "delete")">
Delete
</a>
}
@code {
protected override void OnInitialized()
{
PostBack += (sender, args) =>
{
if (args.EventArgument == "edit")
{
EditItem(args.EventTarget);
}
else if (args.EventArgument == "delete")
{
DeleteItem(args.EventTarget);
}
};
}
}
Alternative: Modern Blazor Approach
If you prefer to modernize away from postback patterns, use @onclick or EventCallback instead:
=== "Simple Case: @onclick" ```razor @foreach (var item in items) { <button @onclick="() => EditItem(item.Id)">Edit <button @onclick="() => DeleteItem(item.Id)">Delete }
@code {
private async Task EditItem(int itemId) { ... }
private async Task DeleteItem(int itemId) { ... }
}
```
=== "Parameterized Case: EventCallback"
```razor
@foreach (var item in items)
{
@code {
private async Task HandleEdit(Item item) { ... }
private async Task HandleDelete(Item item) { ... }
}
<!-- ChildComponent.razor -->
@code {
[Parameter]
public Item Item { get; set; }
[Parameter]
public EventCallback<Item> OnEdit { get; set; }
[Parameter]
public EventCallback<Item> OnDelete { get; set; }
private async Task RaiseEdit() => await OnEdit.InvokeAsync(Item);
private async Task RaiseDelete() => await OnDelete.InvokeAsync(Item);
}
```
Key Differences
| Aspect | Web Forms | Blazor (Phase 2 with Shim) | Blazor (Modern) |
|---|---|---|---|
| Mechanism | __doPostBack() โ HTTP POST | __doPostBack() โ JS interop โ .NET | Direct component method call |
| Server roundtrip | Full page reload | Blazor diff sync (no page reload) | Instant (no roundtrip) |
| Compatibility | Zero rewrite | Zero rewrite | Requires refactoring |
| Best for | Code migration (Phase 1) | Code migration (Phase 2) | New development |
5. Callback Event References (Phase 2)
What It Does
GetCallbackEventReference() generates a JavaScript callback bridge for server callback processing (AJAX-style communication without UpdatePanel). Phase 2 includes a working shim for this pattern.
Web Forms
protected void Page_Load(object sender, EventArgs e)
{
string callback = Page.ClientScript.GetCallbackEventReference(
this,
"arg", // JavaScript argument to pass
"onSuccess", // JavaScript function to call on success
"ctx", // Context object to pass to callback
"onError", // JavaScript function to call on error
true); // useAsync
// Inject the callback string into a JavaScript function
Page.ClientScript.RegisterStartupScript(this.GetType(), "initCallback",
$"var callback = '{callback}'; " +
"function myCallback(arg) { callback(arg); }",
true);
}
// In markup:
// <button onclick="myCallback('someData')">Call Server</button>
// Server-side callback handler:
public void RaiseCallbackEvent(string eventArgument)
{
// Process eventArgument and prepare return value
}
public string GetCallbackResult()
{
// Return result to JavaScript
return "Server processed: " + eventArgument;
}
Blazor with BWFC (Phase 2 โ Zero Rewrite)
// Same pattern works! ClientScriptShim provides the callback bridge
protected override void OnInitialized()
{
string callback = ClientScript.GetCallbackEventReference(
this,
"arg",
"onSuccess",
"ctx",
"onError",
useAsync: true);
// Register the callback into the page
ClientScript.RegisterStartupScript(this.GetType(), "initCallback",
$"var callback = '{callback}'; " +
"function myCallback(arg) { callback(arg); }",
true);
}
// Handler methods (same as Web Forms):
public void RaiseCallbackEvent(string eventArgument)
{
// Process eventArgument
}
public string GetCallbackResult()
{
// Return result to JavaScript
}
How It Works (Phase 2)
-
GetCallbackEventReference()returns a JavaScript function call string that bridges back to .NET:// Returned string looks like: "WebForm_DoCallback('controlId',arg,onSuccess,ctx,onError,true)" -
BWFC ships
bwfc-callback.jswhich definesWebForm_DoCallback()as a bridge:window.WebForm_DoCallback = async function(controlId, arg, onSuccess, ctx, onError, async) { try { const result = await DotNet.invokeMethodAsync('BlazorWebFormsComponents', 'HandleCallbackFromJs', controlId, arg); if (onSuccess) { onSuccess(result, ctx); } } catch (err) { if (onError) { onError(err, ctx); } } }; -
Your C# methods handle the callback, just like Web Forms:
public void RaiseCallbackEvent(string eventArgument) { // Process the callback argument // Set _callbackResult for GetCallbackResult() } public string GetCallbackResult() { // Return data back to the JavaScript callback return _callbackResult; } -
JavaScript receives the result in the
onSuccesscallback:function onSuccess(result, context) { console.log('Server returned:', result); // Update UI with server response }
Usage Pattern
Use callback events for AJAX-style server communication without page reload:
@inject IJSRuntime JS
<button @onclick="FetchDataViaCallback">Fetch Data</button>
<div id="result"></div>
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Define JavaScript callback handlers
await JS.InvokeVoidAsync("eval", @"
window.onCallbackSuccess = function(result, context) {
document.getElementById('result').innerHTML = result;
};
window.onCallbackError = function(error, context) {
console.error('Callback error:', error);
};
");
}
}
private async Task FetchDataViaCallback()
{
// Get the callback reference
string callback = ClientScript.GetCallbackEventReference(
this,
"\"userQuery\"", // Argument to pass
"onCallbackSuccess",
"null",
"onCallbackError",
useAsync: true);
// Execute it via JS interop
await JS.InvokeVoidAsync("eval", $"{callback};");
}
public void RaiseCallbackEvent(string eventArgument)
{
// Process the query
_callbackResult = $"Data for: {eventArgument}";
}
private string _callbackResult = "";
public string GetCallbackResult()
{
return _callbackResult;
}
}
Alternative: Modern Blazor Approach
For new development, use IJSRuntime with direct method calls instead of callbacks:
@inject IJSRuntime JS
@inject HttpClient Http
<button @onclick="FetchData">Fetch Data</button>
<div id="result">@result</div>
@code {
private string result = "";
private async Task FetchData()
{
// Direct async call to server
result = await Http.GetStringAsync("/api/data");
}
}
This is cleaner, type-safe, and easier to test than callback-based patterns.
6. Form Validation Scripts
What It Does
Web Forms uses Page.IsValid and Page.Validate() to check server-side validators. Client-side validation scripts often run before postback to prevent unnecessary round trips.
Web Forms
protected void btnSubmit_Click(object sender, EventArgs e)
{
// Validators run server-side
if (!Page.IsValid)
{
// Show error
return;
}
// Process form
SaveData();
}
// In markup:
// <asp:RequiredFieldValidator ControlToValidate="txtName" />
// <asp:RangeValidator ControlToValidate="txtAge" MinimumValue="0" MaximumValue="120" />
Blazor Equivalent
Use EditForm with EditContext and DataAnnotationsValidator. Validation is declarative (via data annotations) and works on both client and server:
@inject HttpClient Http
<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label for="name">Name:</label>
<InputText id="name" @bind-Value="model.Name" />
<ValidationMessage For="() => model.Name" />
</div>
<div class="form-group">
<label for="age">Age:</label>
<InputNumber id="age" @bind-Value="model.Age" />
<ValidationMessage For="() => model.Age" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</EditForm>
@code {
private FormModel model = new();
private async Task HandleSubmit()
{
// Only called if validation passes
await SaveDataAsync();
}
}
public class FormModel
{
[Required(ErrorMessage = "Name is required")]
public string Name { get; set; }
[Range(0, 120, ErrorMessage = "Age must be between 0 and 120")]
public int Age { get; set; }
}
Key Differences
| Aspect | Web Forms | Blazor |
|---|---|---|
| Declaration | Server-side validator controls | C# data annotations |
| Client-side validation | Rendered JavaScript from validators | Built-in via DataAnnotationsValidator |
| Validation timing | Submit button click โ postback | Form submission or real-time |
| Custom rules | Custom validators or CustomValidator control | ValidationAttribute subclass |
Custom Validators
Web Forms:
<asp:CustomValidator
OnServerValidate="ValidateDateRange"
ErrorMessage="Date must be in the past" />
Blazor:
public class DateInPastAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext ctx)
{
var date = (DateTime?)value;
return date < DateTime.Now
? ValidationResult.Success
: new ValidationResult("Date must be in the past");
}
}
// Usage in model:
[DateInPast]
public DateTime EventDate { get; set; }
7. IPostBackEventHandler โ Custom Event Binding
What It Does
IPostBackEventHandler allows controls to raise custom events in response to postback data. Rarely used directly, but common in composite controls.
Web Forms
public partial class MyCustomControl : UserControl, IPostBackEventHandler
{
public event EventHandler OnCustomAction;
public void RaisePostBackEvent(string eventArgument)
{
if (eventArgument == "myaction")
{
OnCustomAction?.Invoke(this, EventArgs.Empty);
}
}
// Markup triggers postback:
// <a href='<%# Page.ClientScript.GetPostBackEventReference(this, "myaction") %>'>
}
Blazor Equivalent
Use EventCallback<T> parameters instead:
<!-- MyCustomComponent.razor -->
@code {
[Parameter]
public EventCallback OnCustomAction { get; set; }
private async Task RaiseCustomAction()
{
await OnCustomAction.InvokeAsync();
}
}
<!-- Usage in parent: -->
<MyCustomComponent OnCustomAction="HandleCustomAction" />
@code {
private async Task HandleCustomAction()
{
// Handle the event
}
}
With Arguments
If the postback event passes data:
=== "Web Forms"
csharp public void RaisePostBackEvent(string eventArgument) { if (eventArgument.StartsWith("select-")) { string itemId = eventArgument.Replace("select-", ""); OnItemSelected?.Invoke(this, new ItemSelectedEventArgs { ItemId = itemId }); } }
=== "Blazor"
```razor
@code {
[Parameter]
public EventCallback
private async Task SelectItem(string itemId)
{
await OnItemSelected.InvokeAsync(itemId);
}
}
```
8. ScriptManager Code-Behind Patterns
SetFocus()
Web Forms:
ScriptManager.SetFocus(txtUserName);
Blazor:
@inject IJSRuntime JS
<input @ref="userNameRef" />
@code {
private ElementReference userNameRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("focus", userNameRef);
}
}
}
RegisterAsyncPostBackControl()
Web Forms:
ScriptManager.RegisterAsyncPostBackControl(gvData);
// Enables AJAX partial page updates via UpdatePanel
Blazor:
RegisterAsyncPostBackControl() has no equivalent in Blazor because Blazor components handle updates natively via parameter binding and EventCallback. Remove this line.
Instead, let the component update naturally:
@page "/data"
<GridView Data="@items" OnRowSelected="HandleRowSelected" />
@code {
private List<Item> items;
private async Task HandleRowSelected(int itemId)
{
// Component updates automatically via @bind or parameters
var item = await FetchItemAsync(itemId);
items = await FetchItemsAsync(); // Re-render with new data
}
}
RegisterUpdateProgress()
Web Forms:
ScriptManager.RegisterUpdateProgress(updateProgress, masterUpdateProgress);
// Shows during async postback
Blazor: Show a loading indicator using component state:
<div class="update-progress" style="@(isLoading ? "display:block" : "display:none")">
<p>Loading...</p>
</div>
<button @onclick="FetchData" disabled="@isLoading">Fetch</button>
@code {
private bool isLoading;
private async Task FetchData()
{
isLoading = true;
await Task.Delay(2000); // Simulate async work
isLoading = false;
}
}
GetCurrent() and Related Methods (Phase 2)
Web Forms:
ScriptManager sm = ScriptManager.GetCurrent(Page);
sm.RegisterStartupScript(this.GetType(), "init", "initPage();", true);
sm.SetFocus(txtSearch);
Blazor with BWFC (Phase 2 โ Zero Rewrite):
// Same pattern works! ScriptManagerShim wraps ClientScriptShim
ScriptManager sm = ScriptManager.GetCurrent(this); // 'this' is the component (replaces Page)
sm.RegisterStartupScript(this.GetType(), "init", "initPage();", true);
// SetFocus still requires JS interop (see above)
How It Works (Phase 2)
-
ScriptManager.GetCurrent(page)extracts theClientScriptShimfrom the component's dependency injection context. -
All
RegisterStartupScript,RegisterClientScriptBlock,RegisterClientScriptIncludecalls delegate toClientScriptShim, which queues scripts during initialization. -
Scripts are flushed in
OnAfterRenderAsyncviaIJSRuntime, exactly like Phase 1. -
Focus and other component methods require JavaScript interop, as documented in the sections above.
Pattern: RegisterStartupScript via ScriptManager
Instead of calling Page.ClientScript directly, you can use ScriptManager.GetCurrent():
// Web Forms
ScriptManager sm = ScriptManager.GetCurrent(Page);
sm.RegisterStartupScript(this.GetType(), "init", "console.log('loaded');", true);
// Blazor (Phase 2)
ScriptManager sm = ScriptManager.GetCurrent(this);
sm.RegisterStartupScript(this.GetType(), "init", "console.log('loaded');", true);
// Zero code change! Same methods, same behavior
Key Differences
| Method | Phase 1 | Phase 2 | Notes |
|---|---|---|---|
RegisterStartupScript() | โ ClientScriptShim | โ Via ScriptManager | Both work; ScriptManager delegates to ClientScriptShim |
RegisterClientScriptBlock() | โ ClientScriptShim | โ Via ScriptManager | Both work; same delegation |
RegisterClientScriptInclude() | โ ClientScriptShim | โ Via ScriptManager | Both work; same delegation |
GetCurrent() | โ Unsupported | โ Phase 2 | Returns the component's ClientScriptShim |
SetFocus() | โ Unsupported | โ Still not supported | Use JS.InvokeVoidAsync("focus", @ref) instead |
RegisterAsyncPostBackControl() | โ Unsupported | โ Still not supported | UpdatePanel is not emulated; use component binding |
When to Use ScriptManager vs ClientScriptShim
- Directly โ Both
ClientScriptproperty (Phase 1) andScriptManager.GetCurrent()(Phase 2) work - No functional difference โ ScriptManager just wraps ClientScriptShim for API compatibility
- Choose based on your Web Forms code โ If you used
ScriptManager, keep using it; if you usedPage.ClientScript, useClientScriptproperty
9. Common Pitfalls and Solutions
Pitfall 1: Script Runs Multiple Times Due to Re-renders
Problem:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// โ WRONG: Runs every render, not just first
await JS.InvokeVoidAsync("applyTheme");
}
Solution:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// โ
CORRECT: Only on first render
if (firstRender)
{
await JS.InvokeVoidAsync("applyTheme");
}
}
Pitfall 2: Prerendering Issues
In SSR (Server-Side Rendering) or prerendering mode, OnAfterRenderAsync runs on the server without browser interactivity. IJSRuntime calls fail silently.
Problem:
// โ WRONG: Fails during prerendering
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("applyTheme"); // No JS in SSR
}
}
Solution:
// โ
CORRECT: Guard with try-catch or check if interactive
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
await JS.InvokeVoidAsync("applyTheme");
}
catch (InvalidOperationException)
{
// Running in SSR mode; skip JS interop
}
}
}
// OR use a runtime check:
@inject IComponentRenderingContext RenderContext
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && RenderContext.IsInteractive)
{
await JS.InvokeVoidAsync("applyTheme");
}
}
Pitfall 3: Script Timing โ Waiting for DOM Elements
Problem:
// โ WRONG: Element might not exist yet
document.getElementById("myDiv").classList.add("highlight");
Solution:
// โ
CORRECT: Call from OnAfterRenderAsync, after render
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("highlightElement", "myDiv");
}
}
JavaScript:
export function highlightElement(id) {
const elem = document.getElementById(id);
if (elem) {
elem.classList.add("highlight");
}
}
Pitfall 4: Module Import Caching
Problem:
// โ WRONG: Imports module every render
protected override async Task OnAfterRenderAsync(bool firstRender)
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./app.js");
await module.InvokeVoidAsync("init");
}
Solution:
// โ
CORRECT: Cache the module
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>("import", "./app.js");
await module.InvokeVoidAsync("init");
}
}
Pitfall 5: Script Deduplication
In Web Forms, RegisterStartupScript with the same key runs only once per page. In Blazor, you must deduplicate manually.
Problem:
// Component rendered multiple times
foreach (var item in items)
{
<MyComponent />
}
// โ WRONG: Each instance calls applyTheme()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("applyTheme");
}
}
Solution:
<!-- Parent component calls once -->
@foreach (var item in items)
{
<MyComponent Item="@item" />
}
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("applyTheme"); // Once, not per child
}
}
}
Or use a static flag to prevent duplicate initialization:
private static bool isAppInitialized;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !isAppInitialized)
{
isAppInitialized = true;
await JS.InvokeVoidAsync("applyTheme");
}
}
10. What We Don't Support (And Why)
__doPostBack() and Postback Events
Why not?
- Web Forms postback is an HTTP POST with form-encoded data and event validation
- Blazor is component-based with direct method calls, not form postbacks
- Emulating
__doPostBack()would require replicating the entire Web Forms postback protocol, which defeats the purpose of using Blazor
Alternative:
Use @onclick, EventCallback<T>, or form submission with EditForm.
UpdatePanel Async Postback Semantics
Why not?
- UpdatePanel enables partial-page updates via AJAX postback
- Blazor components handle updates natively via parameter binding
- A compatibility layer would be complex, fragile, and undermine Blazor's design
Alternative:
Use Blazor component parameters, @bind, and EventCallback for interactive updates.
Automatic Form Validation Conversion
Why not?
- Web Forms validators are declarative controls with complex state management
- Blazor validation is based on data annotations, which are independent of the component model
- Conversion would require semantic analysis of validator configurations and cannot be automated reliably
Alternative: Manually rewrite validators as data annotations on your model classes.
ScriptManager Full API Surface
Why not?
- Only a few
ScriptManagermethods are commonly used; most are framework internals - Each method has a different (or no) Blazor equivalent
- A full compatibility wrapper would create maintenance burden with minimal benefit
Alternative: Our Roslyn analyzers (BWFC022, BWFC023, BWFC024) detect problematic patterns and guide you to Blazor equivalents.
11. Analyzers and CLI Transforms
To help automate migration detection, BWFC provides three diagnostic rules:
BWFC022: PageClientScript Usage Analyzer
Detects Page.ClientScript usage and suggests patterns for each method call.
Example:
// โ ๏ธ BWFC022: Page.ClientScript is not available in Blazor.
// Migration path depends on the pattern:
// - If RegisterStartupScript(): Use OnAfterRenderAsync(IJSRuntime) with firstRender guard.
// - If RegisterClientScriptInclude(): Add <script> tag to layout or import via JS.InvokeAsync().
// - If GetPostBackEventReference(): Use @onclick or EventCallback<T> instead.
Page.ClientScript.RegisterStartupScript(this.GetType(), "key", "script");
See BWFC022 Reference for details.
BWFC023: IPostBackEventHandler Usage Analyzer
Detects IPostBackEventHandler implementation and suggests EventCallback<T>.
Example:
// โ ๏ธ BWFC023: IPostBackEventHandler is not available in Blazor.
// Use EventCallback<T> for event handling instead.
public class MyControl : BaseWebFormsComponent, IPostBackEventHandler
{
public void RaisePostBackEvent(string eventArgument) { }
}
See BWFC023 Reference for details.
BWFC024: ScriptManager Code-Behind Usage Analyzer
Detects ScriptManager.GetCurrent() and method calls like SetFocus(), RegisterAsyncPostBackControl().
Example:
// โ ๏ธ BWFC024: ScriptManager.GetCurrent() and related methods are not available in Blazor.
// SetFocus: Use JavaScript interop with element @ref.
// RegisterAsyncPostBackControl: Blazor does not use UpdatePanel postback model โ use component binding instead.
ScriptManager.GetCurrent(Page).SetFocus(txtSearch);
See BWFC024 Reference for details.
12. Real-World Examples
Example 1: jQuery Plugin Initialization
Web Forms:
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
Page.ClientScript.RegisterClientScriptInclude(
"jqueryui",
ResolveUrl("~/lib/jquery-ui/jquery-ui.min.js"));
Page.ClientScript.RegisterStartupScript(
this.GetType(),
"initDatepicker",
"$(function() { $('#txtDate').datepicker(); });",
true);
}
}
Blazor:
=== "app.js"
javascript export function initializeDatepicker() { $('#txtDate').datepicker(); }
=== "MyComponent.razor" ```razor @inject IJSRuntime JS
<input @ref="dateInput" id="txtDate" type="text" />
@code {
private ElementReference dateInput;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./app.js");
await module.InvokeVoidAsync("initializeDatepicker");
}
}
}
```
HTML layout must include jQuery UI:
<script src="lib/jquery/jquery.min.js"></script>
<script src="lib/jquery-ui/jquery-ui.min.js"></script>
Example 2: Dynamic Data Grid with Inline Editing
Web Forms:
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
// Include script for inline editing
Page.ClientScript.RegisterClientScriptInclude(
"grideditor",
ResolveUrl("~/lib/grid-editor.js"));
}
}
public class GridData
{
public int Id { get; set; }
public string Name { get; set; }
}
Blazor:
@page "/data-grid"
@inject HttpClient Http
<GridView Data="@items" OnRowSelected="HandleRowSelected">
<GridViewColumn Binding="@(x => x.Id)" Header="ID" />
<GridViewColumn Binding="@(x => x.Name)" Header="Name" />
</GridView>
<button @onclick="Refresh">Refresh</button>
@code {
private List<GridData> items;
protected override async Task OnInitializedAsync()
{
items = await Http.GetFromJsonAsync<List<GridData>>("/api/data");
}
private async Task HandleRowSelected(int id)
{
// Update data directly, no __doPostBack needed
var item = items.FirstOrDefault(x => x.Id == id);
if (item != null)
{
item.Name = await PromptForNewName();
await UpdateItemAsync(item);
}
}
private async Task Refresh()
{
items = await Http.GetFromJsonAsync<List<GridData>>("/api/data");
}
}
public class GridData
{
public int Id { get; set; }
public string Name { get; set; }
}
Example 3: Form with Custom Validation and Theme Toggle
Web Forms:
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
// Validation scripts
Page.ClientScript.RegisterStartupScript(
this.GetType(),
"validate",
"window.validateForm = function() { return $('#form').valid(); };",
true);
// Theme toggle
Page.ClientScript.RegisterStartupScript(
this.GetType(),
"theme",
"$(function() { applyUserTheme(); });",
true);
}
}
protected void btnSubmit_Click(object sender, EventArgs e)
{
if (!Page.IsValid) return;
// Process
}
Blazor:
@page "/form"
@inject IJSRuntime JS
<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label>Name:</label>
<InputText @bind-Value="model.Name" />
<ValidationMessage For="() => model.Name" />
</div>
<div class="form-group">
<label>Email:</label>
<InputText @bind-Value="model.Email" />
<ValidationMessage For="() => model.Email" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
<button type="button" @onclick="ToggleTheme" class="btn btn-secondary">Toggle Theme</button>
</EditForm>
@code {
private FormModel model = new();
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
module = await JS.InvokeAsync<IJSObjectReference>("import", "./app.js");
await module.InvokeVoidAsync("applyUserTheme");
}
}
private async Task HandleSubmit()
{
// Only called if validation passes (DataAnnotationsValidator)
await SaveFormAsync();
}
private async Task ToggleTheme()
{
if (module is not null)
{
await module.InvokeVoidAsync("toggleTheme");
}
}
}
public class FormModel
{
[Required(ErrorMessage = "Name is required")]
public string Name { get; set; }
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email format")]
public string Email { get; set; }
}
Summary
| Web Forms | Blazor | Learn More |
|---|---|---|
RegisterStartupScript() | OnAfterRenderAsync(IJSRuntime) | Section 1 |
RegisterClientScriptInclude() | <script src=""> in layout | Section 2 |
RegisterClientScriptBlock() | JS module + import | Section 3 |
GetPostBackEventReference() | @onclick or EventCallback<T> | Section 4 |
Form validation with Page.IsValid | EditForm + DataAnnotationsValidator | Section 5 |
IPostBackEventHandler | EventCallback<T> | Section 6 |
ScriptManager.SetFocus() | @ref + JS.InvokeVoidAsync() | Section 7 |
ScriptManager other methods | Remove (Blazor handles natively) | Section 7 |
Next Steps:
- Review the Analyzer Reference Pages to understand diagnostic messages
- Check the Roslyn Analyzers documentation for CLI integration
- Explore the Live Samples to see ClientScript patterns in action
- Review the IJSRuntime Documentation for advanced scenarios
Last Updated: 2026-07-30
Status: Complete
Component: Beast (Technical Writer)