All guides
Best practices
You've read getting started and shipped a component or two. This page collects the patterns that keep a Rask app correct, secure, and fast as it grows — the rules the framework rewards, the foot-guns it can't stop for you, and where each one is enforced.
Every item is short on purpose: a rule, why it matters, and a link to the deep dive. Many of these are also compile-time diagnostics (RASK001–034) — when the analyzer can catch a mistake, the rule notes the ID.
- Component design
- Rendering, keys & encoding
- State, callbacks & events
- Context & dependency injection
- Forms & validation
- Routing & lifecycle
- Data access & side effects
- JavaScript interop & refs
- Security
- Accessibility
- Performance & memory
- Testing
- Common pitfalls
Component design
- Make every component
sealed partial. Components aren't an inheritance hierarchy; sealing states intent and keeps the generator's analysis simple, andpartialis where the generator puts the chain surface (RASK036 without it). Every sample does this. - Build components with the chain, never
new.Div.Class("panel"),Counter,RatingStars.Value(3)— the chain wires keys, children, callbacks, and DI thatnewskips. OutsideRask.Core,new-ing a component is RASK014. (Test files that define their ownComponentsubclasses opt out with#pragma warning disable RASK014.) - Inject framework services through the constructor, not as properties. A non-nullable settable
property becomes a required step — so
public IJSRuntime Js { get; set; }would force callers to pass it. Take services as ctor parameters instead:
Combining apublic sealed partial class Weather(IWeatherForecastService service) : Component { /* ... */ }requiredproperty with a DI constructor is contradictory — that's RASK002. - Know what becomes a step. The generator derives the chain from your public
settable properties: non-nullable + no initializer → a required step (a hidden RASK001 suggests
marking it
requiredso it reads as intentional); nullable → an optional setter; an initializer (= ...) → an optional setter;[SkipFactory]orChildren→ excluded. Reach for[SkipFactory]to keep internal state off the chain entirely. See getting started §6.
Rendering, keys & encoding
- Give every list item a stable, unique
.Key(…). Keys are the reconciliation identity the live diff uses to move a row instead of rebuilding it — preserving focus, input value, and scroll on reorder. A keyless list item is RASK022; duplicate sibling keys make the diff fall back to a positional walk (and report a one-timedata-rask-keyerror via the diagnostics seam — treat it as a bug). Use entity IDs, not loop indices. See composition. - Trust
Textfor anything user-supplied; reserveRawfor markup you control. A plain string becomes aTextnode and is HTML-encoded;Raw(...)emits verbatim. User input throughRawis an XSS hole. See getting started → your first component. - Leave the page shell to the framework. The
TApproot renders into<body>; Rask emits the doctype,<html>,<head>and<body>around it (the runtime<script>is appended to<body>,<head>is filled from each component'sHeadAssetsoverride). Rendering the shell yourself is RASK021 — a second document nested inside the body, which the HTML parser silently unwraps. Set<html lang>with theHtmlLangoverride and<body class>withBodyClass; for anything else, overrideShell(head, body)and place both parameters. - Contribute to
<head>via theHeadAssetsoverride, notHead[…]children.Headis a managed slot; passing it children is RASK019. Overrideprotected override Component? HeadAssetsinstead;<title>/<base>are singletons where the last contributor wins. See getting started §7. - Don't fight the attribute order. Universal attributes always render
id, class, style, data-*, role, tabindex, aria-*(typed, then the Aria bag), then tag-specific. Tests assert it and it's stable across releases — match it when asserting on HTML.
State, callbacks & events
- Raise child→parent events with a
Callback<T>prop. Declare it non-nullable (public Callback<int> OnRate { get; set; }) and fire it withawait OnRate.Invoke(n)— unset is a no-op. The parent passes any handler shape, sync or async; the chain step wraps it so invoking it re-renders the parent that owns the lambda, with noStateHasChangedby hand. Write the lambda inside the component so it capturesthis:
A lambda over a plain local or a static method isn't wrapped and won't trigger a re-render. See composition → callbacks.RatingStars.Value(_rating).OnRate(n => _rating = n) // lambda captures this → parent re-renders - Don't expect a handler-only re-render to refire
OnUpdated. Auto-wrapped handlers are excluded from thepropsChangeddiff — changing only the lambda's identity doesn't refire it.Updated*fires when a bound value (a prop, a route/query param) actually changes. See lifecycle → when Updated refires. - Let the runtime re-render for you; call
StateHasChanged()only for out-of-band state. An awaited event handler and eachawaitin an async lifecycle hook auto-re-render. You only callStateHasChanged()by hand for state that changes outside the handler-dispatch window — a timer tick, a fire-and-forget continuation, or an external event/observable you subscribed to inOnMount. - Thread
CancellationTokeninto the async work a handler or hook starts. The token cancels on unmount and — while a handler runs — when the host cancels that dispatch (the server'sHandlerTimeoutor a closed socket). Without it, slow work pins the session's render pipeline:
See composition → cancelling async work and lifecycle → cancellation.Button.OnClick(async () => _rows = await _api.Load(CancellationToken))["Load"]
Context & dependency injection
- Use
Contextto skip prop drilling, not as a general data bus.Context.Provide(value)near the top, thenContext.Get<T>()/Required<T>()/Has<T>()insideRender()below. Reading a context value latches the consumer out of the render cache, so it stays reactive even through a render-cached intermediate — that's the point. Provide a concrete type and consume by an interface if you like. See composition → context. - Always pair a manual subscription with its teardown. If a component above the
Router(a sidebar, breadcrumb) needs to react to navigation or a store, subscribe inOnMountand unsubscribe inOnUnmount— otherwise the publisher keeps a strong reference to the unmounted component:protected override async Task OnMount() => route.Changed += StateHasChanged; protected override async Task OnUnmount() => route.Changed -= StateHasChanged; - A prop that is a mutable collection does not re-render the child when you append to it. Props
are compared with
EqualityComparer<T>.Default, which for aList<T>is reference equality — so a parent that appends to a list it owns and callsStateHasChanged()re-renders itself, while the child holding that same list is served from the render cache and never sees the new entries:
Three ways out, in order of preference: hand the child a fresh snapshot (// the parent appends to _log and re-renders; LogView shows the OLD contents forever LogView.Entries(_log)_log.ToArray()) so the reference genuinely changes; give the child something to subscribe to; or, when the child plainly reads state it does not own and there is no event to subscribe to, opt it out withprotected override bool BypassRenderCache => true;. The same rule is what makesRouterandOutletopt out — they publish per-frame route state the rest of the walk depends on.
Forms & validation
- Bind two-way with a
Bindexpression.Input.Bind(() => _model.Name)replacesValue+OnInput/OnChange+ parsing, and infers the input type from the property's CLR type. A bound control writes the model with the next action; say.Live()on one you read back while the reader is still typing (bind timing). It also replaces them: a bound control installs its own write-back, soValue/Checked/OnInput/OnChangeare not offered on a bound chain (andAfterBindis not offered on a controlled one) — reach forAfterBindwhen you want a side effect on each bound write. See forms §1. - Write the rule beside the field.
Input.Bind(() => _model.Email).Validate(v => …)returns the messages that reject the value; a rule across fields goes onForm.Model(m).Validate(m => …), and either takes anasyncrule. The form owns theEditContext(touched/modified state + the validator pipeline). A model that already carries DataAnnotations attributes or has anAbstractValidator<T>is validated by those too, across the whole reachable object graph, with nothing declared. See forms — validation. - Bind collections with
foreach+ per-item capture — the canonical pattern. Each iteration closes over a distinct instance, so each row owns its validation state andforeachhas no closure trap:
Only reach for the indexer style (foreach (var item in _model.Items) rows.Add(Tr[Td[Input.Bind(() => item.Description)]]);() => _model.Items[i].Name) when you need the row number or replace records rather than mutate them — and then copy the loop index into a per-iteration local. See forms §7. - Keep a field's simple rules in a value object. Required, length and format belong to the value:
expose them as a
static IEnumerable<string> Validate(T value)(the shape of an inline rule) and name it from the field —Input.Bind(() => _form.Price).Validate(Money.Validate)— and enforce it inside the aggregate. One source of truth. See rules in a value object.
Routing & lifecycle
[Route]registers a page; bind URL pieces with[RouteParam]/[QueryParam]. Path segments use[RouteParam], query keys use[QueryParam]— swapping them is RASK006. Bound types must bestringorIParsable<T>(RASK011 otherwise), and must match a route constraint ({id:int}) when present. What the URL does not carry keeps the property's initializer —[QueryParam] public int Page { get; set; } = 1;, and an optional segment left out ({count?}with[RouteParam] public int Count { get; set; } = 5;) alike — and a query value the property cannot hold (?page=two) is read as absent, never an error. Link with the generated, refactor-proofRoutes.Page(...)builder, not hand-written paths: it writes only the query arguments it is given, soRoutes.OrdersPage()is the clean/ordersandRoutes.OrdersPage(Page: 3)is/orders?Page=3— do not make a query property nullable just to keep its key out of a link. See routing.- Navigate from event handlers only.
Go.To,Go.Withand a route's.Go()throw if called duringRender()— it would mid-render the page out from under itself. Load-time redirects belong inOnMountor a route guard, notRender(). See routing → Go. - Put the right work in the right hook.
OnMountfor a one-time load;OnUpdatedto reload when a route/query param changes;OnFirstRender/OnRenderedfor post-paint side effects (it's loop-safe — a re-render elsewhere won't refire it). Eachawaitauto-re-renders, so mutate state after the await and it paints. See lifecycle. - A faulted hook takes the page — an
OnMount/OnUpdatedthat throws, before or after its firstawait, shows the built-in error page in place of the whole page (a first request answers 500). Wrap risky hook work intry/catchto render your own error state, or put anErrorBoundaryaround it. See lifecycle → gotchas. - Never
StateHasChanged()in unmount — the component is already leaving the tree, so it's a no-op by design. UseOnUnmountto tear down subscriptions, nothing more. Compose nested layouts with[ParentRoute]+Outlet.
Data access & side effects
- Register
IDbContextFactory<T>, not a scopedDbContext. A Server session is long-lived; aDbContextis not thread-safe and is meant to be short-lived. Open a fresh context per unit of work and dispose it:
Threadawait using var db = await dbContextFactory.CreateDbContextAsync(CancellationToken); var products = await db.Products.AsNoTracking().ToListAsync(CancellationToken);Component.CancellationTokeninto every async EF call so navigating away cancels in flight. - Load in a lifecycle hook, store in a field, render the field — never query in
Render()(which runs on every event the page hears). For an event-handler mutation, do the work and reload; the awaited handler re-renders on completion automatically. See data access. - Keep EF Core on the Server. The SQLite provider isn't a fit for the trimmed WASM runtime — a
WASM app should reach data through an API. A
decimalis safe to use:UseRaskSqlitefixes the upstream collation bug that otherwise mis-sorts it onde-DEand crashes the process onen-HU. Model money as integer minor units when the table is large and often sorted, for the nativeINTEGERindex — not to dodge a correctness problem.
JavaScript interop & refs
- Type an element ref to its MDN interface, in a field.
new ElementRef<HTMLDialogElement>()keeps its id stable across renders (a local resets each render) and carries the element's MDN members:await _dialog.ShowModal(),await _name.Focus(). See JS interop → element refs. - Inject
IJSRuntimethrough the constructor and call from a hook or handler — interop is only live once the session is up (afterOnMount, or inside handlers). One scoped{Component}.css/{Component}.tssits next to{Component}.csand is auto-included and isolated; orphan or ambiguous assets are RASK015–018, a.jssibling is RASK055, and two scoped components sharing a simple type name collide atwindow.Rask[Name](RASK020). - Put global styles in
wwwroot, not a scoped CSS file. Scoped CSS has no opt-out selector, so a brand palette,:rootvariables or shell tags belong in a plain stylesheet linked from your App'sHead(useLiveOptions.PathBasefor the URL). See JS interop → scoped CSS.
Security
- Order middleware
UseAuthentication()→UseAuthorization()→MapRask<App>(). Rask seeds the session fromHttpContext.Useron the initial GET and the WS upgrade; if auth runs after Rask the principal is empty and every[Authorize]page challenges. This is RASK024. Behind a reverse proxy, wireUseForwardedHeaders()first so the origin checks see the public host. - The session is a cookie, and nothing of it reaches JavaScript.
Rask.Authowns the scheme and sets itHttpOnly,Secure,SameSite=Lax, so there is no token for XSS to steal and none to store in the browser. Don't reintroduce one by hand. - Gate interactive WASM content that renders before its principal lands with the
Authorizecomponent, not route[Authorize]. Route gating runs once, before the page renders; a WASM provider is still hydrating then, soAuthorize(Authorized:, NotAuthorized:, Authorizing:)— whoseAuthorizingslot covers exactly that window — is the right tool. - Lean on the built-in URL sanitization. URL-bearing attributes neutralize dangerous schemes
(
javascript:,vbscript:) toabout:blankby default; useRaskUrl.Trusted(...)only for URLs you control. Treat the session id as a bearer secret (HTTPS only, never logged), and set a strict Content-Security-Policy as middleware beforeMapRask— Rask needs noscript-src 'unsafe-inline'(onlystyle-src 'unsafe-inline'forStyle:attributes, plus'wasm-unsafe-eval'on WASM). Full flows and the security checklist live in authentication.
Accessibility
- Always give
ImganAlt. A meaningful string for informative images,Alt: ""for decorative ones (so screen readers skip them). A missing alt is RASK023. See accessibility. - Reach the full ARIA vocabulary through the
Ariadictionary, with typedRoleandTabIndexfor the two attributes that aren'taria-*. Build higher-level affordances from these primitives plus semantic HTML (Nav,Main,Label.For(…),Th.Scope(…)):Div.Role("status").Aria(new() { ["live"] = "polite" })[_statusMessage]
Performance & memory
- Key your lists — it's a performance rule too. Keyed insert/remove/move ship as small trusted diff ops that preserve DOM identity. Without keys an append, a truncation, or ONE child (or run of children) coming or going among siblings that still match is a diff of exactly those nodes.
- A child that changes element costs its parent, not the page — and a
Keythere is about identity, not bytes.cond ? A : B, words that become an element, a loading line that becomes a table, the page in anOutlet: the parent's children travel as one subtree morph, and nothing outside that parent is touched. So keep what changes shape inside a small element of its own (a status<span>'s wrapper, the<main>around the outlet) rather than directly under<body>, where it is still the whole page. Inside the morphed parent, children are paired by position: give a sibling a.Key(…)when it must stay the node it was — a field being typed in, a scrolled list, a playing video — while something beside it changes shape. In Development each morphed parent is named onRask.Live. See architecture → the fallback ladder. - Treat
Keyas identity, not a reactive signal. Changing a key mounts a fresh instance; it doesn't refireOnUpdated. - Use a
[...]collection expression to avoid a wrapper node for sibling lists, andnullfor a "render nothing" branch (show ? Panel() : null). - Benchmark every render-hotpath or live-runtime change. Diff codec, frame writer, serializer,
and dispatch are under measurement — run
tests/Rask.Benchmarksbefore/after and quote theAllocateddelta. See development workflow.
Testing
- Unit-test first; reach for E2E only when a path is genuinely unreachable by a unit test (the
real JS transports,
rask.jsDOM application, real auth handshakes, browser layout). E2E is heavy. - Render with the right entry point.
ToHtml()for a standalone component; wrap inStubComponentand callRenderAsLiveRoot()for anything needing a live context (handlers, forms, DI). Drive handlers via thedata-rask-on-*id +TryInvokeHandlerAsync, and assert exact attribute order. See testing. - Every
src/Rask.Sitechange gets an E2E test. Add a Playwright journey totests/Rask.Site.E2E.Tests.
Common pitfalls
| Pitfall | Do instead |
|---|---|
List items with no .Key(…) (RASK022) — focus/input lost on reorder |
Chain a stable, unique .Key(…) (entity id) |
new Counter() outside Core (RASK014) |
Name it and chain: Counter, Counter.Value(3) |
| Service as a settable property → required step (RASK002) | Inject via the constructor |
Head[Title[...]] (RASK019) |
Override protected override Component? HeadAssets |
Root renders Doctype/Html/Head/Body (RASK021) |
Return the body's content; Head/HtmlLang/BodyClass/Shell |
User input through Raw(...) (XSS) |
Use a plain string / Text (encodes by default) |
StateHasChanged() inside an awaited handler/hook |
Redundant — the await re-renders for you |
StateHasChanged() in OnUnmount |
No-op by design — only tear down subscriptions |
| Subscribing to an event without unsubscribing | Pair += in OnMount with -= in OnUnmount |
Scoped DbContext in a Server app |
IDbContextFactory<T> + a fresh context per op |
| Async EF/HTTP calls without the token | Thread Component.CancellationToken through |
Any auth token in localStorage |
The HttpOnly session cookie the battery already sets |
UseAuthentication() after MapRask() (RASK024) |
Auth → Authorization → Rask, in that order |
Img without Alt (RASK023) |
Real alt text, or Alt: "" for decorative |
for-loop index captured in a binding lambda |
Copy to a per-iteration local, or use foreach |
See also the diagnostics reference for every RASK0xx ID and its fix, and CLAUDE.md for the contributor-facing map of the framework internals.