Composition: context, callbacks, children & lists
How Rask components talk to each other — passing data down without prop drilling, sending events up, nesting children, and rendering windowed or reorderable lists.
On this page
- Children & fragments
- Component tiers: static method · stateless · stateful
- Hosting a component you built yourself
- Callbacks & context — child→parent callbacks (each runs for the component that wrote it), provide/consume context.
- Lists, toasts, drag & error boundaries — virtualize, keyed lists, toasts, drag-and-drop, error boundaries.
Children & fragments
Children attach through the indexer, not a Children: parameter:
Div.Class("panel")[
Span.Class("title")["Hello"],
"plain text becomes a Text node", // string → Text (HTML-encoded)
items.Select(i => Li.Key(i.Id)[i.Name])
]
Keys must be unique among siblings.
.Key(…)is the reconciliation identity the live diff uses to move a row instead of rebuilding it. If two siblings share a key, keyed reconciliation can't tell them apart, so the diff falls back to a positional walk that may attach a row's DOM state (focus, input value, scroll) to the wrong sibling on reorder. The diff codec reports a one-timedata-rask-key="…"error (via the diagnostics seam) when it detects a duplicate — treat it as a bug to fix, not noise.
For a component,
Keyalso decides which instance is reused. A keyed component is identified by its key rather than by its position among its siblings, which is what keeps the state it holds itself — a private field, an edit buffer, an open/closed toggle, a subscription taken inOnMount— with the item rather than with the slot when the list changes shape. Where on the chain you write it does not matter: steps written beforeKeyare carried onto the instance the key keeps.TodoRow.Key(item.Id).Item(item) // reads best: which item, then what about it TodoRow.Item(item).Key(item.Id) // the same thingA component's key names one component of its type in the component that writes it — a wider scope than an element's, which is its siblings.
Render()builds a child, and runs itsKeystep, before the element it will sit under has been given its children, so the parent cannot ask "the same list?"; it asks "the same key, on the same type, written by me?". Two lists of the same row type in one component therefore need keys that differ between the lists as well:Ul[recent.Select(o => OrderRow.Key($"recent-{o.Id}").Order(o))], Ol[pinned.Select(o => OrderRow.Key($"pinned-{o.Id}").Order(o))]A key written twice still gives each row an instance of its own and its own values — they are told apart by the order they are written in, so the state one holds moves to the other when the first stops being written. Rask reports it once, as a
Rask.Livewarning naming the component, the row type and the key. Keys in different components never meet: a row keyed"a"in a page and one keyed"a"in a component that page renders are two rows.Children of the same type without a key keep their instance too, by their order among the unkeyed ones:
Ui.Modal.Open(_confirming)beside any number of keyedUi.Modals is the same modal on every render, however the keyed ones come, go or reorder around it. Key the ones that are items of a list; leave the fixed ones alone.Without a key, a component is the n-th of its type that its owner writes. The owner is the component whose
Render()builds it: one sequence per type for the wholeRender(), in the order it is written, whichever element each child ends up under (UiInput<string>andUiInput<int>are two types). A callout, an element, or a component of another type that comes or goes ahead of it therefore leaves it alone — its fields, what it loaded inOnMount, whether it is open. A child of its own type coming or going ahead of it does not: every later one of that type moves one place, and takes over the instance — and the state — of its neighbour. Nothing but aKeycan tell those apart, so key a component that is written conditionally ahead of others of its type, just as you key the rows of a list. The same holds for a repeated key, and for the unkeyed children beside keyed ones: among themselves they are told apart by order alone.
Keyis available before a component's required steps too, generic ones included (Ui.Select.Key(id).Value(v)), so a row can say which item it is first. An element is re-specified in full every render, so its instance carries nothing andDiv.Class("line").Key(i)never claims one.
A [...] collection expression renders its items with no wrapping element — use it for a list
of siblings. For the conditional "render nothing" branch, return null (a null child renders
nothing):
show ? Panel() : null // null renders nothing
The
..spread fails inside[…](the compiler parses it as aRange). Pass the enumerable directly —Div[items]— instead ofDiv[..items].
A layout is often a fragment for the same reason — several siblings that share no wrapper:
protected override Component? Render() =>
[Header[H1["My app"]], Main[Outlet], Footer["Contact"]];
The page root is an ordinary component too: it renders into <body>, and Rask composes the document
around it — see the document and the HeadAssets override.
What a layout shows about the page inside it — the last breadcrumb, the tab title — is not passed down or
shared through a service: the page declares PageTitle and the layout reads route.Title, right in the
first HTML. See A page's title.
Hosting a component you built yourself
Components normally enter the tree through their generated chain, and that chain is what registers the instance with its parent. Occasionally you can't call one — because the type isn't known until runtime:
var page = (Component)ActivatorUtilities.CreateInstance(services, pluginType);
Put it in the tree like any other child:
Div.Class("host")[page]
It gets the whole lifecycle — OnMount, OnUpdated, OnFirstRender, OnRendered, OnUnmount — and a handle to re-render
through, exactly as a component a chain built does: the render walk notices an instance nothing registered and
registers it under the component whose subtree it sits in. Several instances of one type under one parent each
keep their own lifecycle.
Constructing a component with
newoutside the framework is a compile error (RASK014) — a type you can name, you build through its chain. Reflection-built instances, whose type the compiler never sees, are the case this is for.
Component tiers: static method · stateless · stateful
There are three ways to author a reusable unit of UI, in ascending order of cost and capability. Reach for the cheapest one that does the job.
namespace Rask.Site.Features;
// Call site: invoke it like any method — no generated factory, no reconciliation identity.
public sealed partial class TierStaticHelperDemo : Component
{
protected override Component? Render() =>
Div.Class("flex gap-2 flex-wrap items-center")[
TierStaticHelper.Badge("inlined"),
TierStaticHelper.Badge("no state"),
TierStaticHelper.Badge("no lifecycle")
];
}Tier 0 — static method
Inlined markup. No instance, no state, no lifecycle.
Tier 1 — stateless component
Props in → markup out. Identity + lifecycle, no fields.
Hello, Ada!
Tier 2 — stateful component
Private field mutated in a handler → auto re-render.
Tier 0 — a plain static method. Just a function that returns markup:
internal static class Tags
{
public static Component Badge(string label) => Span.Class("pill")[label];
}
// call it like any method — Tags.Badge("new")
There is no Component instance, so it has no state, no lifecycle hooks, and no independent
render cache — its markup is inlined into whichever component calls it, on every render of that
caller. It is the leanest way to factor out repeated markup. Two things it cannot do: hold
mutable state, and safely consume ambient context — a static helper has no instance to carry the
"re-run me when context changes" latch, so a Context.Get<T>() inside one only refreshes when its
caller happens to re-render. The moment you need either, promote to Tier 1.
Tier 1 — a stateless component. A Component subclass whose Render() is a pure function of
its props, with no mutable fields:
public sealed partial class Greeting : Component
{
public required string Name { get; set; } // non-nullable, no initializer → a required chain step
protected override Component? Render() => P["Hello, ", Strong[Name], "!"];
}
// name it and chain — Greeting.Name("Ada")
Public settable props become chain steps and setters (see the rules in
the README and lifecycle.md). Over a static method it gains a
reconciliation identity, the lifecycle hooks, render caching, <head>
contribution, and safe context reads — it simply carries no local state.
Tier 2 — a stateful component. A Component subclass that keeps local state in private
fields and mutates them in handlers:
public sealed partial class Counter : Component
{
private int _count;
protected override Component? Render() =>
Button.OnClick(() => _count++)[$"Clicked {_count} times"];
}
The instance persists across renders (Rask reconciles it by its type and its order among the children
of that type its owner writes, or by an explicit Key — see
children), so the field survives. The OnClick lambda captures this, so after it runs the
framework re-renders this component automatically — no StateHasChanged() (the same auto-wrap
that powers callbacks). You only call StateHasChanged() when the
mutation happens off the event-dispatch path (e.g. a background poll loop — see
lifecycle.md).
| Tier | You write | State | Lifecycle | Render cache | Chain | Context reads |
|---|---|---|---|---|---|---|
| 0 · static method | static Component Foo(…) |
none (inlined) | none | none | none — call it | ⚠️ only refreshes with the caller |
| 1 · stateless component | subclass, props → Render(), no fields |
none | ✅ | ✅ | ✅ generated | ✅ |
| 2 · stateful component | subclass with private fields | private fields | ✅ | ✅ | ✅ generated | ✅ |
Rule of thumb: start with a static method; promote to a stateless component when you need an
identity, lifecycle, <head> assets, context-driven re-render, or a clean chain API; promote to
a stateful component when you need mutable local state.
See also: Lifecycle for when OnUpdated refires, and
JS interop for element refs and scoped JS.