Migrating from Blazor
Rask isn't a Blazor replacement so much as a different take on the same problem
space. If you know Blazor, most concepts transfer directly — they just move from
.razor markup into plain C#. This guide maps the day-to-day APIs and calls out the
behavioural differences that trip people up.
New to Rask entirely? Start with getting started.
Concept mapping
| Blazor | Rask |
|---|---|
.razor component (markup + @code) |
sealed partial class : Component with Render() returning a tree |
RenderFragment / Razor markup |
A chain — Div.Class("panel")[Span["hi"]], children via an indexer |
@onclick="Handler" |
.OnClick(() => ...) (a lambda, set by a chain step) |
[Parameter] public T X { get; set; } |
public T X { get; set; } — becomes a chain step (required if non-nullable) or an optional setter |
EventCallback / EventCallback<T> |
Callback / Callback<T> — one property taking a sync (Action/Action<T>) or async (Func<Task>/Func<T,Task>, or a ValueTask) handler |
[CascadingParameter] / CascadingValue |
Context.Provide(value) / Context.Get<T>() / Context.Required<T>() |
@key="x" |
.Key(x) — an ordinary chain step |
OnInitialized / OnInitializedAsync |
OnMount() — one hook; the part before its first await is the synchronous half |
OnParametersSet / OnParametersSetAsync |
OnUpdated() |
OnAfterRender(firstRender) / async |
OnFirstRender() for the firstRender branch, OnRendered() for every render |
Dispose / DisposeAsync |
implement IDisposable / IAsyncDisposable; or use OnUnmount() |
NavigationManager |
Go (event-handler-only) + RouteState (current path/params) |
@page "/path" |
[Route("/path")] on the class — Rask's Route, from Rask.Core.Routing; Blazor's attribute of the same name leaves the page unregistered (RASK071) |
| route/query binding | [RouteParam] / [QueryParam] on a property |
<EditForm> + InputText/InputNumber |
Form.Model(model).OnSubmit(…) + Input.Bind(() => model.X) |
InputDate / InputSelect |
the kit's Ui.DatePicker.Bind(() => model.Day) / Ui.Select.Bind(() => model.Plan).Listbox[…] (UI kit) |
@bind (writes on change) / @bind:event="oninput" |
.Bind(…) writes with the next action; .Blur() on leaving the field; .Live() as it is typed |
<DataAnnotationsValidator> |
nothing — a Form validates its model itself |
<AuthorizeView> (+ Context="user" / @context.User) |
headless Authorize — its .Authorized(user => …) slot receives the ClaimsPrincipal, like @context.User |
AuthenticationStateProvider |
inject IUserProvider and read .Current |
[Inject] T Svc { get; set; } |
constructor injection — partial class Page(T svc) : Component |
Component.razor.css |
sibling {Component}.css next to {Component}.cs |
IJSRuntime |
IJSRuntime (injected via the constructor) |
Side-by-side examples
A parameterised component
@* Blazor: Greeting.razor *@
<h1>Hello, @Name!</h1>
@code {
[Parameter] public string Name { get; set; } = "";
}
// Rask
public sealed partial class Greeting : Component
{
public required string Name { get; set; } // non-nullable → a required chain step
protected override Component? Render() => H1[$"Hello, {Name}!"];
}
// call site: Greeting.Name("Ada")
Event handler + local state
@* Blazor *@
<button @onclick="() => count++">Count: @count</button>
@code { int count; }
// Rask — owner re-renders automatically after the handler
Button.OnClick(() => _count++)[$"Count: {_count}"]
Component → parent callback (no EventCallback)
This is the biggest API difference. Blazor wraps child events in EventCallback so
the parent re-renders. Rask's child declares a Callback<T> prop instead — a struct that
takes either a sync or an async handler, with no InvokeAsync to thread through — and the
chain step that sets it wraps it so invoking it re-renders the parent that owns the lambda
(the lambda's this).
@* Blazor: child *@
<button @onclick="() => OnRate.InvokeAsync(5)">Rate</button>
@code { [Parameter] public EventCallback<int> OnRate { get; set; } }
// Rask: child declares a Callback<int>; parent passes a lambda over its own state
public sealed partial class RatingStars : Component
{
public Callback<int> OnRate { get; set; }
protected override Component? Render() =>
Button.OnClick(() => OnRate.Invoke(5))["Rate"];
}
// parent — the lambda captures `this`, so invoking OnRate re-renders the parent
RatingStars.OnRate(n => _rating = n)
A static method, or a lambda closing over a local instead of this, has no
component target, so no auto re-render fires — write the lambda inside the
component that should update.
Cascading value → Context
@* Blazor *@
<CascadingValue Value="theme"><Component /></CascadingValue>
@* in Component: *@ [CascadingParameter] Theme Theme { get; set; }
// Rask — provide high, read deep (nearest provider wins, matched by type)
Context.Provide(_theme)[ ThemeCard ]
// in any descendant's Render():
var theme = Context.Required<Theme>(); // or Context.Get<T>() (null if absent)
Routing + navigation
@* Blazor *@
@page "/users/{Id:int}"
@inject NavigationManager Nav
@code { [Parameter] public int Id { get; set; } }
// Rask
[Route("/users/{id}")]
public sealed partial class UserPage : Component // nothing injected to navigate
{
[RouteParam] public int Id { get; set; }
[QueryParam] public string? Tab { get; set; }
// navigate from a handler: Routes.HomePage().Go(), Go.With("tab", "x")
}
// type-safe link (generated URL builder) instead of a "/users/42" string:
NavLink.Href(Routes.UserPage(42))["View user"]
Forms
@* Blazor *@
<EditForm Model="model" OnSubmit="Save">
<DataAnnotationsValidator />
<InputText @bind-Value="model.Name" />
</EditForm>
// Rask
Form.Model(_model).OnSubmit(m => Save(m))[
// no validator declared -- the attributes on _model are enforced by the Form itself
Input.Bind(() => _model.Name), // input type inferred from the CLR type
Validation.Message.Template(errs => Div.Class("field-error")[errs[0]]).For(() => _model.Name),
Button.Type(ButtonType.Submit)["Sign up"]
]
Input/Select/Textarea infer their type from the bound property (string →
text, bool → checkbox, int → number, DateOnly → date). Validation.Message and
Validation.Summary are headless — you chain a .Template(…) lambda for the markup. See
forms for nested models, collections, and async validation.
The attributes you already have keep working. A new rule is usually written beside its field instead —
Input.Bind(() => _model.Name).Validate(v => v.Length > 0 ? [] : ["Name is required."]) — see
forms — validation.
Scoped CSS
Identical idea, no association ceremony. Drop a sibling {Component}.css next to
{Component}.cs and it's auto-globbed, scoped to that type, and hot-reloaded under
dotnet watch — Blazor-parity descendant scoping. There is no per-selector opt-out
(no :global(...), no ::deep): global styles for shell tags like body/html, brand
palettes, or framework classes go in a plain wwwroot stylesheet linked from your App's
<Head>.
Behavioural gotchas
Children come through an indexer, not a
Childrenparameter. WriteDiv[Span["hi"], "there"]. There is noChildContentstep, andChildrenis always excluded from the generated chain.The
..spread fails inside[...]. The C# spread element parses as aRangeinside the indexer. Pass enumerables directly instead:Ul[items.Select(i => Li.Key(i.Id)[i.Name])] // ✓ pass the sequence // Ul[.. items.Select(...)] // ✗ parses as RangeF12 / Go-to-Definition on a chain step lands on the generated member, not your component class. Stock Roslyn/ReSharper navigate a generated symbol to its generated document. Use the IDE's "Navigate to Type of Symbol" for a one-action jump to the component class. (Every generated member carries a
<see cref>breadcrumb and[DebuggerStepThrough], so hover/Quick-Doc offers a navigable link and the debugger steps over the generated code into yours.)Keyed lists matter — and Rask warns when you forget. Like Blazor's
@key, pass.Key(…)on list items so inserts/removes/reorders reconcile by identity (preserving focus and input state). A Rask chain in a list-projection context (.Select/.SelectMany, or.Addin a loop) without a.Key(…)raises RASK022 — keyless items reconcile positionally.Gois event-handler-only. Calling it outside a handler throws. Read the current route fromRouteState; change it withGofrom a handler.Lifecycle re-fire rules differ.
Updated*fires on first render and whenever a bound prop or route/query param actually changes — a bare event-handler re-render does not re-fire it. Don't callStateHasChanged()inUnmount*. Faulted async hooks log toConsole.Errorand don't re-render (a placeholder that never resolves usually means an async hook threw).
What stays the same
- DI through the constructor — exactly like the rest of .NET. Framework services
(
RouteState,HttpClient,IJSRuntime) inject the same way as your own services. (No[Inject]properties — a non-nullable settable property would become a required chain step instead.) - Scoped CSS parity — sibling
{Component}.css, descendant-combinator scoping, hot reload. IJSRuntime— the same interop surface (InvokeAsync,InvokeVoidAsync). Rask adds element refs (new ElementRef<HTMLInputElement>()+ a.Ref(…)step on every element, carrying MDN's members:await _name.Focus()) and a sibling{Component}.tsconvention bundled and dispatched aswindow.Rask["{TypeName}"].