You're reading the Rask v1.0.0 guides.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. A child raises an event up to its parent with a plain delegate property — Action, Action<T>, Func<Task>, or Func<T, Task>. DOM event handlers further down use the same four shapes. The chain step wraps the delegate so that invoking it re-renders the parent that owns it, with no StateHasChanged threaded through by hand.


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

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

// 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 delegate is auto-wrapped (so invoking it re-renders the owner): its Invoke 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 delegates are excluded from the propsChanged diff — changing only the lambda identity between renders does not refire OnPropsChanged.

Callbacks on framework components are plain delegates. BsButton.OnClick is an Action?, Input.OnInputAsync a Func<string, Task>?, BsDataGrid.RowClass a Func<T, string?>? — declared as what they are, called back as what they are: button.OnClick?.Invoke(), await (form.OnSubmitAsync?.Invoke(data) ?? Task.CompletedTask).

They briefly were not. While a chain's receiver was the component itself, a delegate-typed property was invocable, so .OnClick(Save) bound to the property and tried to call the handler (CS1593) instead of reaching the setter of the same name — so every callback property wrapped its delegate in a non-invocable carrier struct to get out of the way. A chain receives on Build<TComponent> now: the property is not on the receiver, the lookup never finds it, and the wrappers are gone from the surface entirely. Wrapping is unchanged: a component callback is still auto-wrapped, a DOM handler still is not.

Your own delegate props need none of this; they keep working exactly as above, and their builder setter simply drops the On (.Rate(…) for OnRate). Declare the prop as a carrier if you want the setter to keep the property's name.

DOM events on elements. Element exposes the full DOM GlobalEventHandlers surface — so every element (not a hand-picked few) carries the complete event set, just like the real DOM mixin. Every event ships a typed sync + async pair — a synchronous OnXxx (Action<TArgs>) and an asynchronous OnXxxAsync (Func<TArgs, Task>); set at most one per event (wiring both is a compile error, RASK027 — the runtime would keep the sync one and drop the async). Pass a bare lambda or method groupOnMouseMove: e => { _x = e.OffsetX; }, OnKeyDown: OnKey — never new Action<T>(…): the step already gives the lambda its type, exactly like OnClick: () => _count++. The surface:

  • MouseOnClick (parameterless), OnDoubleClick, OnContextMenu, OnMouseDown/Up/Move/ Enter/Leave/Over/Out, all taking MouseEventArgs (button/buttons, client/screen/page/offset/ movement coords, modifiers).
  • WheelOnWheel (WheelEventArgs: the mouse geometry plus DeltaX/Y/Z + DeltaMode).
  • Pointer & touchOnPointerDown/Up/Move/Enter/Leave/Over/Out/Cancel (PointerEventArgs: mouse geometry + PointerId/Pressure/PointerType/IsPrimary/tilt); OnTouchStart/End/Move/Cancel (TouchEventArgs).
  • FocusOnFocus/OnBlur/OnFocusIn/OnFocusOut (parameterless; reach the element via capture-phase delegation).
  • KeyboardOnKeyDown/OnKeyUp (KeyboardEventArgs: Key "Escape", Code "KeyA", the Shift/Ctrl/Alt/Meta modifiers, Repeat). Focus-scoped; never preventDefault-ed, so handlers compose with normal typing.
  • ClipboardOnCopy/OnCut/OnPaste (ClipboardEventArgs.Text).
  • Scroll & dragOnScroll (ScrollEvent, rAF-coalesced); OnDragStart/Over/Drop/End plus OnDrag/OnDragEnter/OnDragLeave (parameterless — the dragged item's identity rides the handler's closure).
  • FormsOnBeforeInput (Action<string>), OnSelect, OnInvalid, OnReset.
  • MediaAudio/Video add the HTMLMediaElement events OnPlay/OnPause/OnEnded/ OnTimeUpdate/OnVolumeChange/… (MediaEventArgs: current time, duration, paused, volume, …).

You never name the carrier: you pass the lambda or method group and the implicit conversion does the rest, so OnClick: Save and OnClick = Save read exactly as before. 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: el.OnClick?.Invoke(). 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 (it focuses the <dialog> on open via an ElementRef, since a diff-inserted element never fires the HTML autofocus attribute).

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. MouseEventArgs carries button/coords/modifiers, WheelEventArgs adds deltas, ClipboardEventArgs the pasted text. Wiring both OnX and OnXAsync for one event is a compile error (RASK027) — pick one.

EventsDemo.cs

using System.Globalization;
using Rask.Core.Live;

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 _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("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-ui-muted 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("md:col-span-6")[
                Button
                    .Class($"{Tw.BtnOutlinePrimary} w-full py-4")
                    .OnDoubleClick(_ => _doubleClicks++)
                    .OnContextMenu(_ => _contextMenu = !_contextMenu)[
                    "Double-click or right-click me"],
                Div.Class("text-ui-muted mt-2")[
                    $"double-clicks: {_doubleClicks} · context-menu toggled: {_contextMenu}"]
            ],
            // Focus / blur + keyboard on a focusable div.
            Div.Class("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 = e.Key)[
                    Strong["Click to focus, then type"],
                    Div.Class("text-ui-muted mt-2")[
                        $"{(_focused ? "focused" : "blurred")} · last key: {_lastKey}"]
                ]
            ],
            // Clipboard: paste into the box and read the text server-side.
            Div.Class("md:col-span-6")[
                Div
                    .Class("border rounded p-4")
                    .OnPaste(e => _pasted = e.Text)[
                    Strong["Paste text here"],
                    Div.Class("text-ui-muted 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
blurred · last key: —
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() =>
        Button.Type("button").Class(Tw.BtnPrimary).OnClick(() => _clicks++)[UiIcon.Name(UiIconName.Cursor).Class("me-2"), $"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() =>
    [
        Input
            .Value(_typed)
            .Type(InputType.Text)
            .Class($"{Tw.Input} mb-2")
            .Placeholder("Type something")
            .OnInput(v => _typed = v),
        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 string _pick = "rask";

    protected override Component? Render() =>
    [
        Select.Value<string>(null)
            .Class($"{Tw.Select} mb-2")
            .OnChange(v => _pick = v)[
            Option.Value("rask").Selected(_pick == "rask")["Rask"],
            Option.Value("blazor").Selected(_pick == "blazor")["Blazor"],
            Option.Value("htmx").Selected(_pick == "htmx")["htmx"]
        ],
        P.Class("text-sm mb-0")["Picked: ", Strong[_pick]]
    ];
}
Live result

Picked: rask

OnSubmit 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).OnSubmit(OnSubmit).Class("mb-2")[
            Div.Class(Tw.InputGroup)[
                Input.Value<string>(null)
                    .Type(InputType.Text)
                    .Name("name")
                    .Class(Tw.Input)
                    .Placeholder("Your name"),
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.PaperAirplane).Class("me-1"), "Send"]
            ]
        ],
        P.Class("text-sm mb-0")["Last submitted: ", Strong[_submitted]]
    ];

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

    private 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.OnClickAsync(async () =>
    _rows = await _api.LoadAsync(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 plain delegate prop; the framework wraps it so the click re-renders the owning parent — no StateHasChanged:

CallbackRatingDemo.cs

namespace Rask.Site.Features;

public sealed partial class CallbackRatingDemo : Component
{
    private int _rating;

    protected override Component? Render() =>
        Div.Id("callback-rating")[
            // The lambda captures `this`, so it owns this demo — the framework wraps it so clicking
            // a star in the child re-renders the line below, with no extra ceremony.
            RatingStars.Value(_rating).OnRate(n => _rating = n),
            P.Class("mt-2 mb-0 text-sm text-ui-muted")[
                _rating == 0 ? "Click a star to rate." : $"You rated: {_rating}/5"
            ]
        ];
}
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>(_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

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.

ContextThemeDemo.cs

using Rask.Core.Components;
using Rask.Html.Components;

namespace Rask.Site.Features;

// A value provided high in the tree and read deep in the tree, with an intermediate component
// (ThemeCard) that knows nothing about the theme — no prop drilling. Toggling re-renders the
// provider's owner; the deep consumer (ThemeBadge) bypasses the render cache and picks up the
// new value even though the intermediate ThemeCard stays cached between the two.

public sealed record Theme(string Name, bool IsDark)
{
    public static readonly Theme Light = new("Light", false);
    public static readonly Theme Dark = new("Dark", true);
}

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>(_theme)[
            Div
                .Class("border rounded p-3")
                .Style(_theme.IsDark ? "background:#212529;color:#e9ecef" : "background:#f8f9fa")[
                Button.Class($"{Tw.BtnOutlineSecondary} mb-3").Type("button")
                    .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