Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
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

  • Make every component sealed partial. Components aren't an inheritance hierarchy; sealing states intent and keeps the generator's analysis simple, and partial is 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 that new skips. Outside Rask.Core, new-ing a component is RASK014. (Test files that define their own Component subclasses 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:
    public sealed partial class Weather(IWeatherForecastService service) : Component { /* ... */ }
    Combining a required property 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 required so it reads as intentional); nullable → an optional setter; an initializer (= ...) → an optional setter; [SkipFactory] or Children → 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-time data-rask-key error via the diagnostics seam — treat it as a bug). Use entity IDs, not loop indices. See composition.
  • Trust Text for anything user-supplied; reserve Raw for markup you control. A plain string becomes a Text node and is HTML-encoded; Raw(...) emits verbatim. User input through Raw is an XSS hole. See getting started → your first component.
  • Leave the page shell to the framework. The TApp root 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's HeadAssets override). Rendering the shell yourself is RASK021 — a second document nested inside the body, which the HTML parser silently unwraps. Set <html lang> with the HtmlLang override and <body class> with BodyClass; for anything else, override Shell(head, body) and place both parameters.
  • Contribute to <head> via the HeadAssets override, not Head[…] children. Head is a managed slot; passing it children is RASK019. Override protected override Component? HeadAssets instead; <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 with await 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 no StateHasChanged by hand. Write the lambda inside the component so it captures this:
    RatingStars.Value(_rating).OnRate(n => _rating = n)   // lambda captures this → parent re-renders
    A lambda over a plain local or a static method isn't wrapped and won't trigger a re-render. See composition → callbacks.
  • Don't expect a handler-only re-render to refire OnUpdated. Auto-wrapped handlers are excluded from the propsChanged diff — 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 each await in an async lifecycle hook auto-re-render. You only call StateHasChanged() 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 in OnMount.
  • Thread CancellationToken into 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's HandlerTimeout or a closed socket). Without it, slow work pins the session's render pipeline:
    Button.OnClick(async () => _rows = await _api.Load(CancellationToken))["Load"]
    See composition → cancelling async work and lifecycle → cancellation.

Context & dependency injection

  • Use Context to skip prop drilling, not as a general data bus. Context.Provide(value) near the top, then Context.Get<T>() / Required<T>() / Has<T>() inside Render() 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 in OnMount and unsubscribe in OnUnmount — 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 a List<T> is reference equality — so a parent that appends to a list it owns and calls StateHasChanged() re-renders itself, while the child holding that same list is served from the render cache and never sees the new entries:
    // the parent appends to _log and re-renders; LogView shows the OLD contents forever
    LogView.Entries(_log)
    Three ways out, in order of preference: hand the child a fresh snapshot (_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 with protected override bool BypassRenderCache => true;. The same rule is what makes Router and Outlet opt out — they publish per-frame route state the rest of the walk depends on.

Forms & validation

  • Bind two-way with a Bind expression. Input.Bind(() => _model.Name) replaces Value + 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, so Value/Checked/OnInput/OnChange are not offered on a bound chain (and AfterBind is not offered on a controlled one) — reach for AfterBind when 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 on Form.Model(m).Validate(m => …), and either takes an async rule. The form owns the EditContext (touched/modified state + the validator pipeline). A model that already carries DataAnnotations attributes or has an AbstractValidator<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 and foreach has no closure trap:
    foreach (var item in _model.Items)
        rows.Add(Tr[Td[Input.Bind(() => item.Description)]]);
    Only reach for the indexer style (() => _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 be string or IParsable<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-proof Routes.Page(...) builder, not hand-written paths: it writes only the query arguments it is given, so Routes.OrdersPage() is the clean /orders and Routes.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.With and a route's .Go() throw if called during Render() — it would mid-render the page out from under itself. Load-time redirects belong in OnMount or a route guard, not Render(). See routing → Go.
  • Put the right work in the right hook. OnMount for a one-time load; OnUpdated to reload when a route/query param changes; OnFirstRender / OnRendered for post-paint side effects (it's loop-safe — a re-render elsewhere won't refire it). Each await auto-re-renders, so mutate state after the await and it paints. See lifecycle.
  • A faulted hook takes the page — an OnMount/OnUpdated that throws, before or after its first await, shows the built-in error page in place of the whole page (a first request answers 500). Wrap risky hook work in try/catch to render your own error state, or put an ErrorBoundary around it. See lifecycle → gotchas.
  • Never StateHasChanged() in unmount — the component is already leaving the tree, so it's a no-op by design. Use OnUnmount to tear down subscriptions, nothing more. Compose nested layouts with [ParentRoute] + Outlet.

Data access & side effects

  • Register IDbContextFactory<T>, not a scoped DbContext. A Server session is long-lived; a DbContext is not thread-safe and is meant to be short-lived. Open a fresh context per unit of work and dispose it:
    await using var db = await dbContextFactory.CreateDbContextAsync(CancellationToken);
    var products = await db.Products.AsNoTracking().ToListAsync(CancellationToken);
    Thread Component.CancellationToken into 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 decimal is safe to use: UseRaskSqlite fixes the upstream collation bug that otherwise mis-sorts it on de-DE and crashes the process on en-HU. Model money as integer minor units when the table is large and often sorted, for the native INTEGER index — 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 IJSRuntime through the constructor and call from a hook or handler — interop is only live once the session is up (after OnMount, or inside handlers). One scoped {Component}.css / {Component}.ts sits next to {Component}.cs and is auto-included and isolated; orphan or ambiguous assets are RASK015–018, a .js sibling is RASK055, and two scoped components sharing a simple type name collide at window.Rask[Name] (RASK020).
  • Put global styles in wwwroot, not a scoped CSS file. Scoped CSS has no opt-out selector, so a brand palette, :root variables or shell tags belong in a plain stylesheet linked from your App's Head (use LiveOptions.PathBase for the URL). See JS interop → scoped CSS.

Security

  • Order middleware UseAuthentication() → UseAuthorization() → MapRask<App>(). Rask seeds the session from HttpContext.User on 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, wire UseForwardedHeaders() first so the origin checks see the public host.
  • The session is a cookie, and nothing of it reaches JavaScript. Rask.Auth owns the scheme and sets it HttpOnly, 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 Authorize component, not route [Authorize]. Route gating runs once, before the page renders; a WASM provider is still hydrating then, so Authorize(Authorized:, NotAuthorized:, Authorizing:) — whose Authorizing slot covers exactly that window — is the right tool.
  • Lean on the built-in URL sanitization. URL-bearing attributes neutralize dangerous schemes (javascript:, vbscript:) to about:blank by default; use RaskUrl.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 before MapRask — Rask needs no script-src 'unsafe-inline' (only style-src 'unsafe-inline' for Style: attributes, plus 'wasm-unsafe-eval' on WASM). Full flows and the security checklist live in authentication.

Accessibility

  • Always give Img an Alt. 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 Aria dictionary, with typed Role and TabIndex for the two attributes that aren't aria-*. 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 Key there is about identity, not bytes. cond ? A : B, words that become an element, a loading line that becomes a table, the page in an Outlet: 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 on Rask.Live. See architecture → the fallback ladder.
  • Treat Key as identity, not a reactive signal. Changing a key mounts a fresh instance; it doesn't refire OnUpdated.
  • Use a [...] collection expression to avoid a wrapper node for sibling lists, and null for 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.Benchmarks before/after and quote the Allocated delta. 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.js DOM application, real auth handshakes, browser layout). E2E is heavy.
  • Render with the right entry point. ToHtml() for a standalone component; wrap in StubComponent and call RenderAsLiveRoot() for anything needing a live context (handlers, forms, DI). Drive handlers via the data-rask-on-* id + TryInvokeHandlerAsync, and assert exact attribute order. See testing.
  • Every src/Rask.Site change gets an E2E test. Add a Playwright journey to tests/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.

Confirm