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

Composition — callbacks & context

Sending events up from a child and passing values down to deep consumers without prop drilling.

‹ Back to Composition

Callbacks: child → parent

For parent callbacks, Rask has no Blazor-style EventCallback wrapper to thread through. A child raises an event up to its parent with a Callback property (or Callback<T> when the event carries a value), and the caller hands it either a synchronous or an asynchronous handler — Action/Action<T> or Func<Task>/Func<T, Task> — through one step. DOM event handlers further down use the same type. The chain step wraps the handler so that invoking it re-renders the parent that owns it, with no StateHasChanged threaded through by hand.

// Component: declares the event as a Callback<T> prop and invokes it.
public sealed partial class RatingStars : Component
{
    public int Value { get; set; }
    public Callback<int> OnRate { get; set; }

    protected override Component? Render() =>
        Div.Class("inline-flex gap-1")[
            Enumerable.Range(1, 5).Select(i => Button.Key(i).OnClick(() => Rate(i))[i <= Value ? "★" : "☆"])
        ];

    // Raise the event. Unset, it does nothing; a synchronous handler completes without a Task.
    private async Task Rate(int n) => await OnRate.Invoke(n);
}

// Parent: passes a lambda that mutates its own state.
public sealed partial class RatingDemo : Component
{
    private int _rating;

    protected override Component? Render() =>
        Div[
            RatingStars.Value(_rating).OnRate(n => _rating = n),   // re-renders the parent
            P[_rating == 0 ? "Click a star." : $"You rated {_rating}/5"]
        ];
}

When a callback is auto-wrapped (so invoking it re-renders the owner): its handler returns void or Task, it takes 0 or 1 arguments, and the declaring component is not an Element subclass (so DOM handlers like Button.OnClick stay on the free fast path). It must also be over a member of a Component — write the lambda inside the component so it captures this. A lambda over a plain local, or a static method, returns unchanged and does not trigger a re-render.

Auto-wrapped callbacks are excluded from the propsChanged diff — changing only the lambda identity between renders does not refire OnUpdated.

A callback runs for the component that wrote it. The lambda is the parent's code, so the calls it makes that pass no token — await Product.Where(…), Cache.Remember(…) — and the parent's own CancellationToken are cancelled when the parent leaves the page, not when the child that invoked it does. That is what lets a callback take the child off the page and carry on:

_editing ? Editor.Record(_record).OnSaved(async () =>
{
    _editing = false;     // the editor is unmounted
    await Reload();       // still the page's work: it runs to the end
}) : null

The owner is whoever wrote the lambda, however many components handed it on (.OnSaved(OnSaved)), and it holds from a child's event handler and from its lifecycle hook alike. Once the callback returns, the child's code is back under its own lifetime. Only the cancellation changes hands: the user, the services and a configured HandlerTimeout are still the event's, so under a timeout the callback is cancelled by its owner leaving or the timeout passing, whichever is first. The same rule as for re-rendering applies: a lambda over plain locals, a static method, or a new Callback(…) built by hand has no owner and runs for whoever invokes it. See cancellation.

Why a Callback and not a plain delegate. The chain's receiver is the component itself, so .OnClick(Save) is looked up on the component. A delegate-typed property there would be invocable: C# would read the call as invoking the property (CS1593) and never reach the setter of the same name. Callback and Callback<T> are structs, not delegates, so lookup falls through to the step, and the one property takes both handler shapes — there is no OnXxxAsync twin anywhere on the surface. The same holds for a value the framework asks a component for rather than an event: a template or a selector (Ui.Pagination's Href, a page number to a URL) is an Fn<…>, called during the render and never auto-wrapped. An event declared as Action<T> or Func<T, Task> is RASK096, an error whose quick-fix makes it a Callback<T>.

Declare the event non-nullable — public Callback<int> OnRate { get; set; } — and call it back with await OnRate.Invoke(i);. An unset callback is a no-op, so there is nothing to null-check, and it is never a required step: leaving .OnRate(…) off the chain is how a caller says it does not care. Invoke returns a ValueTask that is already complete for a synchronous handler, so the sync path never acquires a Task. Ask OnRate.HasValue only when whether anyone listens changes what you render. A Callback<int>? you already wrote still works. Wrapping is unchanged: a component callback is auto-wrapped, a DOM handler is not.

DOM events on elements. The events are generated from MDN's data: every event MDN's GlobalEventHandlers lists that ships in two browser engines, on every element, named the way MDN names it (click → OnClick, dblclick → OnDblClick) and carrying MDN's own event type, inheritance included — PointerEvent : MouseEvent : UIEvent : Event, with MDN's member names (e.ClientX, e.ShiftKey). Every event is one property, OnXxx, typed Callback<TEvent>, and the step takes the event or nothing, sync or async:

Button.OnClick(Refresh)                        // () => … — the event is optional
Button.OnClick(e => _shift = e.ShiftKey)       // e is MDN's PointerEvent
Button.OnClick(async () => await Save())  // async — awaited before the re-render
Div.OnScroll(e => _top = e.Target!.ScrollTop)  // the target's state, as JavaScript reads it

A component's own argument-less Callback forwards straight in — Button.OnClick(OnClick) — and a handler typed to a base event (Action<MouseEvent>) still receives the whole PointerEvent. Each property's doc comment carries the browser versions and links to MDN and the spec.

There is nothing to choose between and no pair to get wrong: one name, one slot. An async lambda binds the asynchronous overload, never async void. Writing the step twice is simply a duplicated step (RASK044) — the last one wins, as with any other step.

Pass a bare lambda or method group — .OnMouseMove(e => { _x = e.OffsetX; }), .OnKeyDown(OnKey) — never new Action<T>(…): the step already gives the lambda its type. The surface:

  • Mouse & pointer — OnClick/OnAuxClick/OnContextMenu (PointerEvent), OnDblClick and OnMouseDown/Up/Move/Enter/Leave/Over/Out (MouseEvent), OnPointerDown/Up/Move/… (PointerEvent), OnWheel (WheelEvent).
  • Touch — OnTouchStart/End/Move/Cancel (TouchEvent: Touches, TargetTouches, ChangedTouches — lists of Touch in MDN's shape).
  • Keyboard — OnKeyDown/OnKeyUp (KeyboardEvent: Key "Escape", Code "KeyA", ShiftKey/CtrlKey/AltKey/MetaKey, Repeat). Never preventDefault-ed, so handlers compose with normal typing. Compare against Keys and Codes rather than string literals: one constant per value UI Events defines (Keys.Escape, Keys.ArrowDown, Codes.KeyQ, Codes.Space), generated with the elements, so a typo is a compile error and they work as patterns and switch cases — e.Key is Keys.Escape, case Keys.Home or Keys.PageUp:. A key that types a character reports the character ("q", " "), which no constant names.
  • Clipboard — OnCopy/OnCut/OnPaste (ClipboardEvent.ClipboardData.GetData("text/plain")).
  • Focus, forms, drag — OnFocus/OnBlur/OnFocusIn/OnFocusOut (FocusEvent), OnBeforeInput (InputEvent: Data, InputType), OnSelect/OnInvalid/OnReset, and the drag events (DragEvent).
  • Open state — OnToggle/OnBeforeToggle (ToggleEvent: OldState/NewState, "open" or "closed"); a <dialog>'s OnCancel (Escape or a light dismiss) and OnClose (any close, after OnCancel).
  • Scroll & media — MDN's scroll and media events are plain Events, so their state travels as the target's, the way JavaScript reads e.target: e.Target.ScrollTop/ClientHeight/ScrollHeight (rAF-coalesced) and e.Target.CurrentTime/Duration/Paused/Volume/PlaybackRate/… for OnTimeUpdate, OnPlay, OnVolumeChange and the rest.

The quirks come from MDN's data too: an event that does not bubble (focus, scroll, the media events) reaches only the element it fired on, and only contextmenu/dragover/drop — which need it to work at all — are preventDefault-ed; every other listener is passive.

You never name the Callback: you pass the lambda or method group and the step does the rest. It exists so a property and its builder setter can share a name — a delegate-typed property is invocable, which would make .OnClick(Save) try to call the handler (CS1593). Reading a handler back off an element is the one place it shows: await el.OnClick.Invoke(e). DOM handlers are never auto-wrapped — they go straight to the DOM, where handler-owner resolution already re-renders the owner.

All of these are delegated by a single capture-phase listener per event in the shared client module (rask-events.ts, imported by both the Server and WASM runtimes), so there is no per-element JS. The Todos sample uses OnKeyDown to close its dialog on Escape (e.Key is Keys.Escape); the key lands in the dialog because its title field carries autofocus, which the runtime acts on for an element a render brings (accessibility).

The full surface, live — every readout updates from a plain field mutation, no StateHasChanged:

Every handler just mutates a field; the framework re-renders the component that owns the callback, so the readouts update on their own. MouseEvent carries button/coords/modifiers, WheelEvent adds deltas, ClipboardEvent the pasted text. KeyboardEvent's Key and Code compare against Keys.Escape / Codes.KeyQ, UI Events' values as generated constants. One Callback per event takes a sync or an async handler: one name, one slot.

EventsDemo.cs
using System.Globalization;

namespace Rask.Site.Features;

// Live demo for the full GlobalEventHandlers surface. Every handler below mutates a field and the
// framework re-renders THIS component automatically (the handler's closure captures `this`), so there
// is not a single StateHasChanged in here — derived readouts just update. One element, many events.
public sealed partial class EventsDemo : Component
{
    private double _x, _y;
    private bool _hovering;
    private int _wheel;
    private int _doubleClicks;
    private bool _contextMenu;
    private bool _focused;
    private string _lastKey = "—";
    private string _lastCode = "—";
    private string _pasted = "—";

    private static string Fmt(double d) => d.ToString("0", CultureInfo.InvariantCulture);

    protected override Component? Render() =>
        Div.Class("grid grid-cols-12 gap-4")[
            // Pointer tracking pad: mousemove + enter/leave + wheel, all typed.
            Div.Class("col-span-12 md:col-span-6")[
                Div
                    .Class("border rounded p-4 text-center user-select-none")
                    .Style(_hovering ? "background:#eef6ff" : null)
                    .OnMouseMove(e => { _x = e.OffsetX; _y = e.OffsetY; })
                    .OnMouseEnter(_ => _hovering = true)
                    .OnMouseLeave(_ => _hovering = false)
                    .OnWheel(e => _wheel += (int)e.DeltaY)[
                    Strong["Move / scroll here"],
                    Div.Class("text-zinc-500 dark:text-white/70 mt-2")[
                        $"x: {Fmt(_x)}, y: {Fmt(_y)} · {(_hovering ? "inside" : "outside")} · wheel Σ {_wheel}"]
                ]
            ],
            // Double-click + context menu (preventDefault'd client-side so the native menu is suppressed).
            Div.Class("col-span-12 md:col-span-6")[
                Ui.Button.Class("w-full py-4")
                    .OnDblClick(_ => _doubleClicks++)
                    .OnContextMenu(_ => _contextMenu = !_contextMenu)["Double-click or right-click me"],
                Div.Class("text-zinc-500 dark:text-white/70 mt-2")[
                    $"double-clicks: {_doubleClicks} · context-menu toggled: {_contextMenu}"]
            ],
            // Focus / blur + keyboard on a focusable div. Keys.Escape clears it: Keys and Codes
            // carry UI Events' key and code values as constants, so they are patterns and switch cases too.
            Div.Class("col-span-12 md:col-span-6")[
                Div
                    .Class("border rounded p-4")
                    .TabIndex(0)
                    .Style(_focused ? "outline:2px solid #0d6efd" : null)
                    .OnFocus(() => _focused = true)
                    .OnBlur(() => _focused = false)
                    .OnKeyDown(e => (_lastKey, _lastCode) = e.Key is Keys.Escape ? ("—", "—") : (e.Key, e.Code))[
                    Strong["Click to focus, then type (Escape clears)"],
                    Div.Class("text-zinc-500 dark:text-white/70 mt-2")[
                        $"{(_focused ? "focused" : "blurred")} · last key: {_lastKey} · code: {_lastCode}"]
                ]
            ],
            // Clipboard: paste into the box and read the text server-side.
            Div.Class("col-span-12 md:col-span-6")[
                Div
                    .Class("border rounded p-4")
                    .OnPaste(e => _pasted = e.ClipboardData?.GetData("text/plain") ?? "")[
                    Strong["Paste text here"],
                    Div.Class("text-zinc-500 dark:text-white/70 mt-2")[$"pasted: {_pasted}"]
                ]
            ]
        ];
}
Live result
Move / scroll here
x: 0, y: 0 · outside · wheel Σ 0
double-clicks: 0 · context-menu toggled: False
Click to focus, then type (Escape clears)
blurred · last key: — · code: —
Paste text here
pasted: —

And the everyday handlers on their own — a click counter, onInput, OnChange on a select, and onSubmit (which receives a FormData of the named fields):

EventsClickDemo.cs
namespace Rask.Site.Features;

public sealed partial class EventsClickDemo : Component
{
    private int _clicks;

    protected override Component? Render() =>
        Ui.Button.Primary.Icon(Ui.IconName.CursorArrowRays).OnClick(() => _clicks++)[$"Clicks: {_clicks}"];
}
Live result
EventsInputDemo.cs
namespace Rask.Site.Features;

public sealed partial class EventsInputDemo : Component
{
    private string _typed = string.Empty;

    protected override Component? Render() =>
    [
        Ui.Input.Value(_typed).Label("Type something")
            .Type(InputType.Text)
            .Placeholder("Type something")
            .OnInput(v => _typed = v).Class("mb-2"),
        P.Class("text-sm mb-0")[
            "You typed: ",
            Code[string.IsNullOrEmpty(_typed) ? "\"\"" : $"\"{_typed}\""]
        ]
    ];
}
Live result

You typed: ""

EventsSelectDemo.cs
namespace Rask.Site.Features;

public sealed partial class EventsSelectDemo : Component
{
    private static readonly (string Value, string Text)[] Frameworks =
        [("rask", "Rask"), ("blazor", "Blazor"), ("htmx", "htmx")];

    private string _pick = "rask";

    protected override Component? Render() =>
    [
        Ui.Select.Value(_pick).Listbox
            .Label("Framework")
            .OnChange(v => _pick = v)
            .Class("mb-2")[
            Frameworks.Select(framework => Ui.SelectOption.Key(framework.Value).Value(framework.Value)[framework.Text])
        ],
        P.Class("text-sm mb-0")["Picked: ", Strong[_pick]]
    ];
}
Live result
Rask
Blazor
htmx

Picked: rask

OnAnySubmit receives a FormData object collected from all named form fields.

EventsFormDemo.cs
using Rask.Core.Live;

namespace Rask.Site.Features;

public sealed partial class EventsFormDemo : Component
{
    private string _submitted = "(none yet)";

    // `Form` binds a model; this demo posts raw FormData, so the model is just the field it posts.
    private readonly Fields _fields = new();

    protected override Component? Render() =>
    [
        Form.Model(_fields).OnAnySubmit(OnAnySubmit).Class("mb-2")[
            Div.Class("flex items-stretch gap-2")[
                Ui.Input.Of<string>()
                    .Label("Your name")
                    .Type(InputType.Text)
                    .Name("name")
                    .Placeholder("Your name"),
                Ui.Button.Primary.Icon(Ui.IconName.PaperAirplane).Submit["Send"]
            ]
        ],
        P.Class("text-sm mb-0")["Last submitted: ", Strong[_submitted]]
    ];

    private void OnAnySubmit(FormData fd)
    {
        var name = fd.Get("name");
        _submitted = string.IsNullOrWhiteSpace(name) ? "(blank)" : name;
    }

    public sealed class Fields
    {
        public string? Name { get; set; }
    }
}
Live result

Last submitted: (none yet)

Cancelling async work. Component.CancellationToken is cancelled when the component unmounts — and, while an event handler is running, also when the host cancels that dispatch (the server's optional RaskServerOptions.HandlerTimeout elapsing, or the socket closing). Thread it into the cancellable async work a handler or lifecycle hook starts, so the work aborts when the component goes away and a slow handler unwinds instead of pinning the session's render pipeline:

Button.OnClick(async () =>
    _rows = await _api.Load(CancellationToken))["Load"]

It is cooperative: a handler that ignores the token (or runs unbounded synchronous work) can't be force-aborted — that's a .NET reality, not a Rask limitation. In a lifecycle hook (no handler dispatch) the token is simply the component's lifetime token.

A child raises an event through a Callback<T> prop, fired with await OnRate.Invoke(n); the framework wraps the parent's handler so the click re-renders the owning parent — no StateHasChanged:

namespace Rask.Site.Features;

// A reusable child component that knows nothing about its parent's state. It renders clickable
// stars and emits the chosen rating up through a Callback<int> prop — non-nullable, and still
// optional: unset, `await OnRate.Invoke(i)` does nothing. The child wraps the call in its own click
// handler (so the DOM event only dirties the child) — yet the parent still re-renders, because the
// framework auto-wraps the handler to re-render its owner. No StateHasChanged threaded through by hand.
public sealed partial class RatingStars : Component
{
    public int Value { get; set; }
    public Callback<int> OnRate { get; set; }

    protected override Component? Render() =>
        Div.Class("inline-flex gap-1")[
            // Key first: it says which star this is before anything is said about it.
            //
            // The filled/empty colours are TOKENS now, not #ffc107 and #ced4da. A hardcoded hex ignores the
            // theme, so the filled star stayed amber on a palette with no amber in it and the empty one was
            // invisible on anything dark.
            Enumerable.Range(1, 5).Select(i => Ui.Button.Subtle.Key(i)
                .Class("text-2xl leading-none " + (i <= Value ? "text-amber-700 dark:text-amber-400" : "text-zinc-500 dark:text-white/70"))
                .OnClick(() => OnRate.Invoke(i))[i <= Value ? "★" : "☆"])
        ];
}
Live result

Click a star to rate.


Context: provide / consume

Context passes a value from high in the tree to a deep consumer without prop drilling — React's provide/consume, type-erased so it stays trim-safe.

// Provide near the top. `Provide<T>` is a transparent node; children render under it.
Context.Provide(_theme)[
    ThemeCard        // knows nothing about Theme — no prop passed through it
]

// Consume anywhere below, in Render():
public sealed partial class ThemeBadge : Component
{
    protected override Component? Render()
    {
        var theme = Context.Required<Theme>();   // throws if no provider
        return Span.Class(theme.IsDark ? "badge bg-dark" : "badge bg-light")[theme.Name];
    }
}

Read APIs (call inside Render()):

Call Behaviour
Context.Get<T>() nearest value, or null if no provider
Context.Required<T>() nearest value, or throws
Context.Has<T>() true if a provider exists

Context answers only while Render() runs. A task you start there that reads it later finds no provider, so read the value in Render() and hand it to the task.

Nearest provider wins, matched by optional Name: plus IsAssignableFrom — so you can provide a concrete type and consume by an interface. A provider supplying null still resolves (it is a real provider of null).

Reactivity: reading a context value latches the consumer out of the render cache, so it re-reads when the provider re-renders — even through a render-cached intermediate that never re-renders itself. That is the whole point: ThemeCard above is cached after first paint, yet the ThemeBadge it nests still updates on every toggle.

using Rask.Core.Components;

namespace Rask.Site.Features;

public sealed partial class ContextThemeDemo : Component
{
    private Theme _theme = Theme.Light;

    protected override Component? Render() =>
        // Provide the current theme to the whole subtree below.
        Context.Provide(_theme)[
            Div
                .Class("border rounded p-3")
                .Style(_theme.IsDark ? "background:#212529;color:#e9ecef" : "background:#f8f9fa")[
                Ui.Button.Class("mb-3")
                    .OnClick(() => _theme = _theme.IsDark ? Theme.Light : Theme.Dark)[$"Toggle theme — currently {_theme.Name}"],
                // ThemeCard has no idea a theme exists; it just renders structure + a badge.
                ThemeCard
            ]
        ];
}
Live result
Deeply nested, no theme prop passed in:
☀️ Light

Confirm