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'sHeadoverride). 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 theHeadoverride, notHead()children.Head()is a managed slot; passing it children is RASK019. Overrideprotected override Component? Headinstead;<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-*, 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 plain delegate prop. There is no
EventCallbacktype — useAction,Action<T>,Func<Task>, orFunc<T, Task>. 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
OnPropsChanged. Auto-wrapped delegates are excluded from thepropsChangeddiff — changing only the lambda's identity doesn't refire it.OnPropsChanged*fires when a bound value (a prop, a route/query param) actually changes. See lifecycle → when OnPropsChanged 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.OnClickAsync(async () => _rows = await _api.LoadAsync(CancellationToken))["Load"]
Context & dependency injection
- Use
Contextto skip prop drilling, not as a general data bus.Context.Provide<T>(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 void OnMount() => route.Changed += StateHasChanged; protected override void 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.stringfields update per keystroke; other types update on blur. 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. - Wrap inputs in
Form<TModel>and let it validate. The form owns theEditContext(touched/modified state + the validator pipeline) and registers the built-in passes itself, so the model's attributes and theAbstractValidator<T>you wrote both cover the whole reachable object graph — including nested sub-objects and collections — with nothing declared. Reach for an inlineValidate:lambda on top of that when a rule belongs to one field on one form. See validation.md. - 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. - Reuse one validation rule across the form and the domain. A value object that exposes its rule
as a
static IEnumerable<string> Validate(T value)(the shape of an inline validator) can be passed as a method group toInput(() => _form.Price).Validate(Money.Validate)and enforced inside the aggregate — one source of truth. See data access.
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. Link with the generated, refactor-proofRoutes.Page(...)builder, not hand-written paths. See routing.- Navigate from event handlers only. Every
Navigatormethod throws if called duringRender()or the initial GET — it would mid-render the page out from under itself. Load-time redirects belong in a route guard, notRender(). See routing → Navigator. - Put the right work in the right hook.
OnMountAsyncfor a one-time load;OnPropsChangedAsyncto reload when a route/query param changes;OnRenderedAsyncfor 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 async hook is silent — the framework logs to
Console.Errorand does not re-render or surface an error. The classic symptom is a component stuck on its loading placeholder. Wrap risky hook work intry/catchto render your own error state, or use anErrorBoundary. 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 keystroke). 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
- Mint element refs with
ElementRef.New()stored in a field. A field keeps the ref id stable across renders (a local resets each render). Pass it viaRef:, then hand it to JS or a built-in helper (_input.FocusAsync(_js)). 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()→UseRask<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 beforeUseRask— 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; keyless structural changes fall back to a full-HTML morph. See architecture → keyed reconciliation.
- Treat
Keyas identity, not a reactive signal. Changing a key mounts a fresh instance; it doesn't refireOnPropsChanged. - 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
benchmarks/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
site/change gets an E2E test. Add a Playwright journey totests/Rask.Examples.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? Head |
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 UseRask() (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.