You're reading the Rask v1.0.0 guides.View source
All guides

Composition — lists, toasts, drag & error boundaries

Windowed and reorderable lists, transient toast messages, drag-and-drop, and error boundaries.

‹ Back to Composition

Virtualize — windowed lists

Virtualize.Items<T> is headless: you render the scroll container and rows from a VirtualizationState ctx, and it tells you which slice is visible. The first argument is the body builder; pass exactly one of Items (positional or named) or ItemsProvider, plus ItemSize (row height in px, required) and optional OverscanCount.

It is the one place on the surface that is a method call rather than a chain, and for a reason a chain cannot work around: a chain infers its type argument from the step that opens it, and T here comes from the render delegate, not from a leading step. Virtualize is a global alias for the class that holds it, so no using is needed.


Virtualize.Items<Row>(
    ctx => Div.Style("height:400px; overflow:auto;").OnScroll(ctx.OnScroll)[
        Div.Style($"height:{ctx.OffsetBefore}px"),          // top spacer
        Table[Tbody[
            ctx.VisibleItems.Select(item => Tr.Style($"height:{ctx.ItemSize}px;").Data(new() { ["rask-key"] = item.Index.ToString() })[  // key → reuse <tr> on scroll
                Td[item.IsPlaceholder ? "—" : item.Value!.Name])
        ]],
        Div.Style($"height:{ctx.OffsetAfter}px")             // bottom spacer
    ],
    _rows,                 // Items (in memory)
    ItemSize: 32,
    OverscanCount: 4)

For lazy / server-paged data pass ItemsProvider: instead of the items list. The provider must propagate the CancellationToken it receives or it will leak in-flight requests:


ItemsProvider: async req =>
{
    var page = await _api.GetRowsAsync(req.StartIndex, req.Count, req.CancellationToken);
    return new ItemsProviderResult<Row>(page.Items, page.TotalCount);
}

Provider mode caches by index, marks rows IsPlaceholder while a page is in flight, and cancels + disposes superseded requests (and on unmount).

Items mode — a fixed in-memory list, windowed:

VirtualizeItemsDemo.cs

using Rask.Core.Virtualization;

namespace Rask.Site.Features;

// In-memory virtualization: 10,000 rows in VirtualizeData.Rows, but only the visible window
// (plus a small overscan) ever reaches the DOM. The off-screen rows above and below are reserved
// by two spacer *rows* inside the tbody, so the single table is the scroller's only child.
public sealed partial class VirtualizeItemsDemo : Component
{
    // The header stays put while scrolling because (a) the table is the scroller's only child and
    // its total height is constant — the spacer rows just redistribute height inside tbody as the
    // window moves — so the sticky header's containing block never resizes, and (b) sticky lives on
    // the <th> cells (not <thead>), each painting an opaque background with an inset box-shadow
    // divider. Earlier the spacers were divs *outside* the table: the table's own box was relaid out
    // every scroll frame and the header unstuck (vanished mid-scroll, snapped back on stop).
    private const string StickyHead =
        "position:sticky; top:0; z-index:1; background:#f8f9fa; box-shadow:inset 0 -1px 0 #dee2e6; ";

    protected override Component? Render() =>
        Virtualize.Items<VirtualizeRow>(
            ctx => Div
                .Class("border rounded bg-white")
                .Style("height:360px; overflow:auto;")
                .Data(new Dictionary<string, string?> { ["testid"] = "virtualize-scroller" })
                .OnScroll(ctx.OnScroll)[
                Table
                    .Class($"{Tw.Table} text-sm mb-0")
                    .Style("table-layout:fixed; width:100%; border-collapse:separate; border-spacing:0;")[
                    Thead[
                        Tr[
                            Th.Style(StickyHead + "width:64px;")["#"],
                            Th.Style(StickyHead)["Name"],
                            Th.Style(StickyHead + "width:120px;")["City"],
                            Th.Style(StickyHead + "width:110px; text-align:right;")["Balance"]
                        ]
                    ],
                    Tbody[BodyRows(ctx)]
                ]
            ],
            VirtualizeData.Rows,
            ItemSize: 32,
            OverscanCount: 4,
            InitialClientHeight: 360);

    // tbody = top spacer + the visible window + bottom spacer. Every child carries a stable
    // data-rask-key so the whole tbody stays on the keyed reconciliation path (it's all-or-nothing):
    // the spacers keep their identity and only their height attribute is patched as you scroll, while
    // the real rows move by index — all trusted diff ops, so the header is never re-rendered.
    private static IEnumerable<Component> BodyRows(VirtualizationContext<VirtualizeRow> ctx)
    {
        yield return Spacer(ctx.OffsetBefore, "spacer-before");

        foreach (var item in ctx.VisibleItems)
        {
            yield return Tr
                .Style($"height:{ctx.ItemSize}px;")
                .Data(new Dictionary<string, string?>
                {
                    ["row-index"] = item.Index.ToString(),
                    ["rask-key"] = item.Index.ToString()
                })[
                Td[item.Value?.Index.ToString() ?? ""],
                Td[item.Value?.Name ?? ""],
                Td[item.Value?.City ?? ""],
                Td.Style("text-align:right;")[item.Value?.Balance.ToString("0.00") ?? ""]
            ];
        }

        yield return Spacer(ctx.OffsetAfter, "spacer-after");
    }

    private static Component Spacer(int height, string key) =>
        Tr
            .Style($"height:{height}px;")
            .Data(new Dictionary<string, string?> { ["rask-key"] = key })[
            Td.Colspan(4)
        ];
}
Live result
#NameCityBalance
1Ada Lovelace #00001Amsterdam140.90
2Grace Lovelace #00002New York522.76
3Linus Lovelace #00003New York262.59
4Margaret Lovelace #00004Oxford512.92
5Donald Lovelace #00005New York761.25
6Barbara Lovelace #00006Helsinki257.32
7Edsger Lovelace #00007Cambridge320.23
8Tony Lovelace #00008Boston260.24
9Alan Lovelace #00009Cambridge35.31
10John Lovelace #00010Manchester577.19
11Ada Hopper #00011Boston152.19
12Grace Hopper #00012London705.15
13Linus Hopper #00013Manchester539.97
14Margaret Hopper #00014London711.15
15Donald Hopper #00015New York906.21
16Barbara Hopper #00016Amsterdam516.41

Provider mode — rows fetched on demand as they scroll into view:

VirtualizeProviderDemo.cs

using Rask.Core.Virtualization;

namespace Rask.Site.Features;

// The same component, now backed by an ItemsProvider that simulates a 350 ms API call per
// window. Visible rows show a "—" placeholder until the fetch resolves, then morph in.
// Navigating away mid-fetch cancels the in-flight call: VirtualizeModel cancels its
// CancellationTokenSource in OnUnmount (and supersedes it whenever a new viewport arrives),
// so honour req.CancellationToken in your own providers to let the cancellation propagate.
public sealed partial class VirtualizeProviderDemo : Component
{
    // Sticky header on the <th> cells, kept rock-steady by the constant-height single table —
    // see VirtualizeItemsDemo for the full why (spacer rows inside tbody, not divs outside it).
    private const string StickyHead =
        "position:sticky; top:0; z-index:1; background:#f8f9fa; box-shadow:inset 0 -1px 0 #dee2e6; ";

    protected override Component? Render() =>
        Virtualize.Items(
            ctx => Div
                .Class("border rounded bg-white")
                .Style("height:360px; overflow:auto;")
                .Data(new Dictionary<string, string?> { ["testid"] = "virtualize-async-scroller" })
                .OnScroll(ctx.OnScroll)[
                Table
                    .Class($"{Tw.Table} text-sm mb-0")
                    .Style("table-layout:fixed; width:100%; border-collapse:separate; border-spacing:0;")[
                    Thead[
                        Tr[
                            Th.Style(StickyHead + "width:64px;")["#"],
                            Th.Style(StickyHead)["Name"],
                            Th.Style(StickyHead + "width:120px;")["City"],
                            Th.Style(StickyHead + "width:110px; text-align:right;")["Balance"]
                        ]
                    ],
                    Tbody[BodyRows(ctx)]
                ]
            ],
            ItemsProvider: FetchRowsAsync,
            ItemSize: 32,
            OverscanCount: 4,
            InitialClientHeight: 360);

    // tbody = top spacer + visible window + bottom spacer, every child keyed so the whole tbody
    // stays on the trusted keyed-diff path (spacers patch only their height; rows move by index).
    private static IEnumerable<Component> BodyRows(VirtualizationContext<VirtualizeRow> ctx)
    {
        yield return Spacer(ctx.OffsetBefore, "spacer-before");

        foreach (var item in ctx.VisibleItems)
        {
            yield return Tr
                .Style($"height:{ctx.ItemSize}px;")
                .Data(new Dictionary<string, string?>
                {
                    ["row-index"] = item.Index.ToString(),
                    ["rask-key"] = item.Index.ToString(),
                    ["placeholder"] = item.IsPlaceholder ? "true" : null
                })[
                Td[item.IsPlaceholder ? "—" : item.Value!.Index.ToString()],
                Td[item.IsPlaceholder ? "—" : item.Value!.Name],
                Td[item.IsPlaceholder ? "—" : item.Value!.City],
                Td.Style("text-align:right;")[item.IsPlaceholder ? "—" : item.Value!.Balance.ToString("0.00")]
            ];
        }

        yield return Spacer(ctx.OffsetAfter, "spacer-after");
    }

    private static Component Spacer(int height, string key) =>
        Tr
            .Style($"height:{height}px;")
            .Data(new Dictionary<string, string?> { ["rask-key"] = key })[
            Td.Colspan(4)
        ];

    private static async ValueTask<ItemsProviderResult<VirtualizeRow>> FetchRowsAsync(ItemsProviderRequest req)
    {
        // The token is honoured so navigating away from /virtualize while a fetch is in flight
        // unwinds the Task.Delay promptly. Without the token check the delay would complete and
        // the continuation would try to update the cache after the component was disposed.
        await Task.Delay(350, req.CancellationToken).ConfigureAwait(false);
        var rows = VirtualizeData.Rows;
        var count = Math.Min(req.Count, rows.Length - req.StartIndex);
        var slice = new VirtualizeRow[Math.Max(count, 0)];
        for (var i = 0; i < slice.Length; i++)
        {
            slice[i] = rows[req.StartIndex + i];
        }

        return new ItemsProviderResult<VirtualizeRow>(slice, rows.Length);
    }
}
Live result
#NameCityBalance

Keyed lists

A .Key(…) on a list item gives it a stable identity across renders, so a reorder moves the live DOM node (with its focus, caret, and uncommitted input) instead of detaching and re-creating it. This is the same reconciliation identity the diff uses everywhere — not a reactive prop.

KeyedListsReorderDemo.cs

namespace Rask.Site.Features;

// Keyed reconciliation in miniature. A stable Key: per row makes a reorder ship trusted Move ops, so
// each row's DOM node — and any uncommitted input value living only in the DOM — follows its logical row
// instead of being rewritten by position. Toggle Keys OFF to see positional reconciliation instead: the
// labels reorder but the inputs stay put, so typed text ends up next to the wrong fruit.
public sealed partial class KeyedListsReorderDemo : Component
{
    private readonly List<Fruit> _items =
    [
        new(1, "Apple"),
        new(2, "Banana"),
        new(3, "Cherry"),
        new(4, "Date"),
        new(5, "Elderberry")
    ];

    private int _nextId = 6;

    private bool _useKeys = true;

    protected override Component? Render() =>
        Div[
            Div.Class("flex gap-2 items-center flex-wrap mb-3")[
                Button
                    .Class(_useKeys ? $"{Tw.BtnSuccess}" : $"{Tw.BtnOutlineSecondary}")
                    .Id("kl-toggle-keys")
                    .OnClick(() => _useKeys = !_useKeys)[
                    UiIcon.Name(_useKeys ? UiIconName.Key : UiIconName.Key).Class("me-1"),
                    _useKeys ? "Keys: ON" : "Keys: OFF"
                ],
                Span.Class("vr mx-1"),
                Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("kl-rotate").OnClick(Rotate)[
                    UiIcon.Name(UiIconName.ArrowsUpDown).Class("me-1"), "Rotate"
                ],
                Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("kl-reverse").OnClick(Reverse)[
                    UiIcon.Name(UiIconName.Retry).Class("me-1"), "Reverse"
                ],
                Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("kl-add").OnClick(AddTop)[
                    UiIcon.Name(UiIconName.Plus).Class("me-1"), "Add to top"
                ],
                Button.Type("button").Class(Tw.BtnOutlineDanger)
                    .Id("kl-remove")
                    .Disabled(_items.Count == 0)
                    .OnClick(RemoveTop)[
                    UiIcon.Name(UiIconName.Minus).Class("me-1"), "Remove top"
                ]
            ],
            Ul.Class(Tw.ListGroup).Id("kl-list")[BuildRows()]
        ];

    private List<Component> BuildRows()
    {
        var rows = new List<Component>(_items.Count);
        for (var i = 0; i < _items.Count; i++)
        {
            var f = _items[i];
            // The keyless branch is deliberately unkeyed to demonstrate positional
            // reconciliation; RASK022 would otherwise flag it.
#pragma warning disable RASK022
            rows.Add(_useKeys
                ? Li.Key(f.Id).Class($"{Tw.ListGroupItem} flex items-center gap-3")[Row(f, i)]
                : Li.Class($"{Tw.ListGroupItem} flex items-center gap-3")[Row(f, i)]);
#pragma warning restore RASK022
        }

        return rows;
    }

    private static List<Component> Row(Fruit f, int index) =>
    [
        Span.Class(Tw.BadgeSecondary)[index + 1],
        Span.Class("font-semibold").Style("min-width: 7rem;")[f.Name],
        Input.Value<string>(null)
            .Type(InputType.Text)
            .Class($"{Tw.Input} kl-note")
            .Placeholder("type here, then reorder…")
    ];

    private void Rotate()
    {
        if (_items.Count < 2)
        {
            return;
        }

        var first = _items[0];
        _items.RemoveAt(0);
        _items.Add(first);
    }

    private void Reverse() => _items.Reverse();

    private void AddTop()
    {
        _items.Insert(0, new Fruit(_nextId, $"Fruit {_nextId}"));
        _nextId++;
    }

    private void RemoveTop()
    {
        if (_items.Count > 0)
        {
            _items.RemoveAt(0);
        }
    }

    private sealed record Fruit(int Id, string Name);
}
Live result
  • 1Apple
  • 2Banana
  • 3Cherry
  • 4Date
  • 5Elderberry

A master-detail grid is the same identity trick at work: each order row carries a Key, and expanding one inserts a keyed detail <tr> right after it. The diff reconciles that as an in-place keyed insert (collapse → remove), so the other open rows keep their own independently-sorted inner grid across the change — no wholesale re-render of the table:

MasterDetailDemo.cs

using System.Globalization;

namespace Rask.Site.Features;

// Master-detail datagrid, embedded in docs/composition.md's "Keyed lists" section (its standalone
// /master-detail page folded in). Expand/collapse and both grids' sort live in plain component fields.
//
// Expanding a row inserts a second, keyed <tr> ("detail-{id}") right after the keyed main row ("{id}").
// Because every row carries a stable Key, the live diff treats expand as an in-place keyed Insert and
// collapse as a keyed Remove — sibling expanded rows keep their own inner sort across the reconcile. Each
// detail panel hosts its own plain <table> of line items with an independent sort, so the demo owns three
// pieces of state: the expanded set, the outer sort, and a per-order inner sort.
public sealed partial class MasterDetailDemo : Component
{
    private static readonly Order[] _orders = BuildOrders();

    // (id, header, sortable) — the expander column has no label and no sort.
    private static readonly (string Id, string Header, bool Sortable)[] _orderColumns =
    [
        ("expander", "", false),
        ("customer", "Customer", true),
        ("placed", "Placed", true),
        ("status", "Status", true),
        ("items", "Items", true),
        ("total", "Total", true)
    ];

    private static readonly (string Id, string Header)[] _itemColumns =
    [
        ("sku", "SKU"),
        ("product", "Product"),
        ("qty", "Qty"),
        ("unit", "Unit price"),
        ("line", "Line total")
    ];

    // Expanded order ids, the outer sort, and the inner sort per order — all local UI state.
    // A sort is a (column id, ascending) pair; an empty column id means "unsorted".
    private readonly HashSet<int> _expanded = new();
    private readonly Dictionary<int, (string Col, bool Asc)> _itemSort = new();
    private (string Col, bool Asc) _orderSort = ("", true);

    protected override Component? Render()
    {
        var orders = SortOrders(_orders, _orderSort);

        return Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class("overflow-x-auto")[
                Table.Id("md-orders").Class($"{Tw.Table} [&_tbody_tr:hover]:bg-ui-well align-middle mb-0")[
                    Thead.Class("bg-ui-well")[
                        Tr[_orderColumns.Select(c =>
                            c.Sortable
                                ? SortHeader(c.Id, c.Header, _orderSort, ToggleOrderSort)
                                : Th.Scope("col").Key(c.Id))]
                    ],
                    Tbody[BuildOrderRows(orders)]
                ]
            ]
        ];
    }

    private List<Component> BuildOrderRows(IReadOnlyList<Order> orders)
    {
        var rows = new List<Component>(orders.Count * 2);
        foreach (var order in orders)
        {
            var open = _expanded.Contains(order.Id);

            rows.Add(Tr.Key(order.Id).Class("md-row")[
                Td.Style("width:44px;")[
                    Button
                        .Class($"{Tw.BtnLink} p-0 no-underline")
                        .Data(new Dictionary<string, string?> { ["testid"] = $"expander-{order.Id}" })
                        .OnClick(() => Toggle(order.Id))[
                        UiIcon.Name(open ? UiIconName.ChevronDown : UiIconName.ChevronRight)
                    ]
                ],
                Td.Class("font-semibold")[order.Customer],
                Td.Class("text-ui-muted text-sm")[order.Placed.ToString("yyyy-MM-dd")],
                Td[Span.Class(StatusBadge(order.Status))[order.Status]],
                Td.Class("text-ui-muted")[order.Items.Count],
                Td.Style("text-align:right; font-variant-numeric:tabular-nums;")[
                    "$" + order.Total.ToString("N2", CultureInfo.InvariantCulture)
                ]
            ]);

            if (open)
            {
                rows.Add(Tr.Key($"detail-{order.Id}").Class("md-detail")[
                    Td.Colspan(_orderColumns.Length).Class("p-0 bg-ui-well")[
                        Div
                            .Class("p-3")
                            .Data(new Dictionary<string, string?> { ["testid"] = $"inner-{order.Id}" })[
                            InnerGrid(order)
                        ]
                    ]
                ]);
            }
        }

        return rows;
    }

    private Component InnerGrid(Order order)
    {
        var sort = _itemSort.GetValueOrDefault(order.Id, ("", true));
        var items = SortItems(order.Items, sort);

        return Table.Class($"{Tw.Table} text-sm [&_tbody_tr:nth-child(odd)]:bg-ui-well align-middle mb-0 bg-white")[
            Thead[
                Tr[_itemColumns.Select(c =>
                    SortHeader(c.Id, c.Header, sort, col => ToggleItemSort(order.Id, col)))]
            ],
            Tbody[
                items.Select(it =>
                    Tr.Key(it.Id)[
                        Td[Code[it.Sku]],
                        Td[it.Product],
                        Td.Class("text-ui-muted")[it.Qty],
                        Td.Style("text-align:right; font-variant-numeric:tabular-nums;")[
                            "$" + it.UnitPrice.ToString("N2", CultureInfo.InvariantCulture)
                        ],
                        Td.Style("text-align:right; font-variant-numeric:tabular-nums;")[
                            "$" + it.LineTotal.ToString("N2", CultureInfo.InvariantCulture)
                        ]
                    ])
            ]
        ];
    }

    private void Toggle(int id)
    {
        if (!_expanded.Add(id))
        {
            _expanded.Remove(id);
        }

        StateHasChanged();
    }

    private void ToggleOrderSort(string col)
    {
        _orderSort = NextSort(_orderSort, col);
        StateHasChanged();
    }

    private void ToggleItemSort(int orderId, string col)
    {
        _itemSort[orderId] = NextSort(_itemSort.GetValueOrDefault(orderId, ("", true)), col);
        StateHasChanged();
    }

    // Cycle a column's sort: unsorted → asc → desc → unsorted.
    private static (string Col, bool Asc) NextSort((string Col, bool Asc) current, string col) =>
        current.Col != col ? (col, true)
        : current.Asc ? (col, false)
        : ("", true);

    private static IReadOnlyList<Order> SortOrders(IReadOnlyList<Order> source, (string Col, bool Asc) sort)
    {
        if (sort.Col.Length == 0)
        {
            return source;
        }

        var asc = sort.Asc;
        IEnumerable<Order> view = source;
        view = sort.Col switch
        {
            "customer" => asc ? view.OrderBy(o => o.Customer) : view.OrderByDescending(o => o.Customer),
            "placed" => asc ? view.OrderBy(o => o.Placed) : view.OrderByDescending(o => o.Placed),
            "status" => asc ? view.OrderBy(o => o.Status) : view.OrderByDescending(o => o.Status),
            "items" => asc ? view.OrderBy(o => o.Items.Count) : view.OrderByDescending(o => o.Items.Count),
            "total" => asc ? view.OrderBy(o => o.Total) : view.OrderByDescending(o => o.Total),
            _ => view
        };
        return view.ToArray();
    }

    private static IReadOnlyList<LineItem> SortItems(IReadOnlyList<LineItem> source, (string Col, bool Asc) sort)
    {
        if (sort.Col.Length == 0)
        {
            return source;
        }

        var asc = sort.Asc;
        IEnumerable<LineItem> view = source;
        view = sort.Col switch
        {
            "sku" => asc ? view.OrderBy(i => i.Sku) : view.OrderByDescending(i => i.Sku),
            "product" => asc ? view.OrderBy(i => i.Product) : view.OrderByDescending(i => i.Product),
            "qty" => asc ? view.OrderBy(i => i.Qty) : view.OrderByDescending(i => i.Qty),
            "unit" => asc ? view.OrderBy(i => i.UnitPrice) : view.OrderByDescending(i => i.UnitPrice),
            "line" => asc ? view.OrderBy(i => i.LineTotal) : view.OrderByDescending(i => i.LineTotal),
            _ => view
        };
        return view.ToArray();
    }

    // Shared sort-aware header: a link button that toggles the column's sort, with a chevron reflecting
    // its current direction. Used by both the outer and the inner grid.
    private static Component SortHeader(string columnId, string header, (string Col, bool Asc) sort,
        Action<string> toggle)
    {
        var sorted = sort.Col == columnId;
        var icon = sorted
            ? sort.Asc ? UiIconName.ChevronUp : UiIconName.ChevronDown
            : UiIconName.ArrowsUpDown;

        return Th.Scope("col").Key(columnId)[
            Button
                .Type("button")
                .Class($"{Tw.BtnLink} p-0 no-underline text-ui-ink font-semibold inline-flex" +
                       "items-center gap-1")
                .OnClick(() => toggle(columnId))[
                Span[header],
                UiIcon.Name(icon).Class(sorted ? "text-xs" : "text-xs opacity-50")
            ]
        ];
    }

    private static string StatusBadge(string status) => status switch
    {
        "Shipped" => Tw.BadgeSuccess,
        "Processing" => Tw.BadgePrimary,
        "Pending" => Tw.BadgeWarning,
        "Cancelled" => Tw.BadgeDanger,
        _ => Tw.BadgeSecondary
    };

    private static Order[] BuildOrders()
    {
        var customers = new[]
        {
            "Ada Lovelace", "Grace Hopper", "Linus Torvalds", "Margaret Hamilton", "Donald Knuth",
            "Barbara Liskov", "Edsger Dijkstra", "Tony Hoare", "Alan Turing", "John Backus",
            "Niklaus Wirth", "Bjarne Stroustrup", "Anders Hejlsberg", "James Gosling"
        };
        var statuses = new[] { "Shipped", "Processing", "Pending", "Cancelled" };
        var products = new[]
        {
            ("KBD-01", "Mechanical keyboard", 89m), ("MSE-02", "Wireless mouse", 39m),
            ("MON-27", "27\" monitor", 329m), ("USB-C", "USB-C hub", 59m),
            ("CBL-HD", "HDMI cable", 12m), ("STD-01", "Laptop stand", 45m),
            ("WBC-4K", "4K webcam", 129m), ("HPN-01", "Noise-cancelling headphones", 199m),
            ("DSK-MAT", "Desk mat", 24m), ("CHR-ERG", "Ergonomic chair", 449m)
        };

        var rng = new Random(42);
        var orders = new Order[customers.Length];
        var nextItemId = 1;
        for (var i = 0; i < customers.Length; i++)
        {
            var itemCount = 2 + rng.Next(0, 5);
            var items = new LineItem[itemCount];
            for (var j = 0; j < itemCount; j++)
            {
                var (sku, name, price) = products[rng.Next(products.Length)];
                items[j] = new LineItem(nextItemId++, i + 1, sku, name, 1 + rng.Next(0, 5), price);
            }

            var placed = new DateOnly(2025, 1 + rng.Next(0, 12), 1 + rng.Next(0, 28));
            orders[i] = new Order(i + 1, customers[i], placed, statuses[rng.Next(statuses.Length)], items);
        }

        return orders;
    }

    private sealed record LineItem(int Id, int OrderId, string Sku, string Product, int Qty, decimal UnitPrice)
    {
        public decimal LineTotal => Qty * UnitPrice;
    }

    private sealed record Order(
        int Id,
        string Customer,
        DateOnly Placed,
        string Status,
        IReadOnlyList<LineItem> Items)
    {
        public decimal Total => Items.Sum(i => i.LineTotal);
    }
}
Live result
Ada Lovelace2025-04-15Processing5$1,843.00
Grace Hopper2025-02-03Pending3$1,522.00
Linus Torvalds2025-01-16Cancelled6$2,488.00
Margaret Hamilton2025-08-05Shipped5$1,420.00
Donald Knuth2025-09-02Processing5$858.00
Barbara Liskov2025-01-24Shipped2$732.00
Edsger Dijkstra2025-12-13Processing2$801.00
Tony Hoare2025-09-01Cancelled5$2,263.00
Alan Turing2025-12-22Pending6$711.00
John Backus2025-01-07Pending5$4,068.00
Niklaus Wirth2025-01-02Cancelled4$1,597.00
Bjarne Stroustrup2025-12-28Cancelled6$2,509.00
Anders Hejlsberg2025-07-20Shipped2$1,011.00
James Gosling2025-10-04Shipped4$1,891.00

Toast messages

IToaster is Rask's take on flash messages — transient, consumed-once user messages that survive a client-side navigation. Inject it and queue a message; a single ToastOutlet shows it once.


public sealed partial class SavePage(IToaster toast, Navigator nav) : Component
{
    private void Save()
    {
        // ... persist ...
        toast.Success("Your changes were saved.");   // Info / Warning / Error / Add(level, …) too
        nav.NavigateTo(Routes.ListPage());            // the message survives the navigation
    }
    // ...
}

Why it survives the navigation: IToaster is registered scoped per session (a Server WebSocket session or a WASM app instance), and a client-side NavigateTo does not recreate the session — so a message queued before navigating is still in the queue when the destination mounts.

Show them by mounting one outlet in your app layout. The headless ToastOutlet ships no markup — you own it through Template, which receives the messages plus a dismiss(id) callback:


ToastOutlet.Template((messages, dismiss) =>
    Div[messages.Select(m => (Component)Div.Class("notice").Key(m.Id.ToString())[
        m.Message,
        Button.OnClick(() => dismiss(m.Id))["×"]])])

ToastOutlet calls Consume() (which drains the queue) on mount and whenever IToaster.Changed fires, so each message is delivered to exactly one outlet and never reappears on a later render. Set AutoDismissAfter to have each message clear itself after a delay — a one-shot timer per message that runs the same dismiss path, so any Template auto-dismisses even when its element has no timer of its own. A toast outlet is a fixed container of messages that auto-hide after 5 s by default (set AutoHideMs: null to keep them sticky); mount a single BsToaster() in your layout instead of writing a Template. Queue one, show once (this demo auto-dismisses after 5 s):

Drag and drop

A headless drag-and-drop primitive lives in Rask.Core/DragAndDrop. It tracks the dragged item and the drop target and raises a callback when an item is dropped; you own the visuals — a sortable list and a kanban board built on the same primitive:

DragDropSortableDemo.cs

using Rask.Core.DragAndDrop;

namespace Rask.Site.Features;

// Single-list reorder. One drop zone ("list"); drop a fruit onto another to reorder.
public sealed partial class DragDropSortableDemo : Component
{
    private readonly List<string> _fruits =
    [
        "Apple", "Banana", "Cherry", "Date", "Elderberry"
    ];

    protected override Component? Render() => DragDrop.Body(SortableBody).OnDrop(ReorderFruit);

    private Component SortableBody(DragDropContext ctx)
    {
        var rows = new List<Component>(_fruits.Count);
        for (var i = 0; i < _fruits.Count; i++)
        {
            var fruit = _fruits[i];
            var index = i;
            var cls = "list-group-item d-flex align-items-center gap-2 dd-item";
            if (ctx.IsSource("list", index))
            {
                cls += " dd-dragging";
            }

            if (ctx.IsDropTarget("list", index))
            {
                cls += " dd-drop-target";
            }

            rows.Add(Li
                .Key(fruit)
                .Class(cls)
                .Draggable(true)
                .OnDragStart(ctx.DragStart("list", index))
                .OnDragOver(ctx.DragOver("list", index))
                .OnDropAsync(ctx.Drop("list", index))
                .OnDragEnd(ctx.DragEnd)
                .Data(new Dictionary<string, string?> { ["testid"] = $"fruit-{index}" })[
                UiIcon.Name(UiIconName.Grip).Class("text-ui-muted"),
                Span.Class("font-semibold")[fruit]
            ]);
        }

        return Ul.Class($"{Tw.ListGroup} dd-list").Id("dd-fruit-list")[rows];
    }

    // Direction-aware: dragging down lands after the target, dragging up lands before it.
    private void ReorderFruit(DragDropMove move) => move.ApplyTo(_fruits);
}
Live result
  • Apple
  • Banana
  • Cherry
  • Date
  • Elderberry
DragDropKanbanDemo.cs

using Rask.Core.DragAndDrop;

namespace Rask.Site.Features;

// Multi-column board. One drop zone per column; drag cards across columns or reorder within one.
public sealed partial class DragDropKanbanDemo : Component
{
    private static readonly string[] _columns = ["todo", "doing", "done"];

    private static readonly Dictionary<string, string> _columnLabels = new()
    {
        ["todo"] = "To do",
        ["doing"] = "In progress",
        ["done"] = "Done"
    };

    private readonly Dictionary<string, List<Card>> _board = new()
    {
        ["todo"] =
            [new Card(1, "Sketch the API"), new Card(2, "Write the primitive"), new Card(3, "Add the events")],
        ["doing"] = [new Card(4, "Wire the client JS")],
        ["done"] = [new Card(5, "Read the codebase")]
    };

    protected override Component? Render() => DragDrop.Body(KanbanBody).OnDrop(MoveCard);

    private Component KanbanBody(DragDropContext ctx)
    {
        var cols = new List<Component>(_columns.Length);
        foreach (var zone in _columns)
        {
            var cards = _board[zone];
            var cardChildren = new List<Component>(cards.Count);
            for (var i = 0; i < cards.Count; i++)
            {
                var card = cards[i];
                var index = i;
                var cls = "card dd-card";
                if (ctx.IsSource(zone, index))
                {
                    cls += " dd-dragging";
                }

                if (ctx.IsDropTarget(zone, index))
                {
                    cls += " dd-drop-target";
                }

                cardChildren.Add(Div
                    .Key(card.Id)
                    .Class(cls)
                    .Draggable(true)
                    .OnDragStart(ctx.DragStart(zone, index))
                    .OnDragOver(ctx.DragOver(zone, index))
                    .OnDropAsync(ctx.Drop(zone, index))
                    .OnDragEnd(ctx.DragEnd)
                    .Data(new Dictionary<string, string?> { ["testid"] = $"card-{card.Id}" })[
                    Div.Class($"{Tw.CardBody} p-2 flex items-center gap-2")[
                        UiIcon.Name(UiIconName.Grip).Class("text-ui-muted"),
                        Span[card.Title]
                    ]
                ]);
            }

            // The whole column body is the drop-at-end zone, so a card can land in empty space, at
            // the tail of a column, or into an empty column. Cards inside carry their own per-index
            // drop handlers; the client's e.target.closest(...) resolves the innermost match, so
            // hovering a card targets that card and hovering empty space targets the column end.
            var dropAtEnd = cards.Count;
            var bodyCls = "dd-column-body";
            if (ctx.IsDropTarget(zone, dropAtEnd))
            {
                bodyCls += " dd-drop-target";
            }

            cols.Add(Div.Key(zone).Class("col-span-12")[
                Div.Class("dd-column h-full")[
                    Div.Class("dd-column-header flex justify-between items-center")[
                        Span.Class("font-semibold")[_columnLabels[zone]],
                        Span.Class(Tw.BadgeSecondary)[cards.Count.ToString()]
                    ],
                    Div
                        .Class(bodyCls)
                        .OnDragOver(ctx.DragOver(zone, dropAtEnd))
                        .OnDropAsync(ctx.Drop(zone, dropAtEnd))
                        .Data(new Dictionary<string, string?> { ["testid"] = $"col-{zone}" })[cardChildren]
                ]
            ]);
        }

        return Div.Class("grid grid-cols-12 gap-4 dd-board")[cols];
    }

    private void MoveCard(DragDropMove move)
    {
        if (_board.TryGetValue(move.FromZone, out var from) && _board.TryGetValue(move.ToZone, out var to))
        {
            move.ApplyTo(from, to);
        }
    }

    private sealed record Card(int Id, string Title);
}
Live result
To do3
Sketch the API
Write the primitive
Add the events
In progress1
Wire the client JS
Done1
Read the codebase

Error boundaries

An error boundary catches an exception thrown by a descendant — during an event handler or during render — and shows a fallback instead of tearing down the whole app. The nearest boundary handles it; everything outside it (the navbar, the rest of the page) keeps running, and Recover restores the healthy subtree. Boundaries nest, so a local failure stays local.

BoomHandlerDemo.cs

namespace Rask.Site.Features;

// ErrorBoundary catches exceptions thrown by a descendant's event handler and
// renders the Fallback in place of the subtree. The fallback receives a
// recover() callback that clears the boundary's error and re-renders the
// healthy subtree.
public sealed partial class BoomHandlerDemo : Component
{
    protected override Component? Render() =>
        ErrorBoundary
            .Fallback(BoundaryFallback)[
            Div.Class("p-3 border rounded bg-white").Id("boom-handler-host")[
                P.Class("text-ui-muted text-sm mb-2")["Healthy subtree — click to throw."],
                Button.Type("button").Class(Tw.BtnDanger).Id("boom-throw").OnClick(ThrowFromHandler)[UiIcon.Name(UiIconName.Warning).Class("me-2"),
                    "Throw a handler exception"]
            ]
        ];

    private static Component BoundaryFallback(Exception ex, Action recover) =>
        Div.Class($"{Tw.AlertDanger} flex items-start").Id("boom-fallback")[
            UiIcon.Name(UiIconName.Warning).Class("me-3 size-6"),
            Div[
                Strong["Boundary caught: "],
                Code.Class("ms-1")[ex.GetType().Name],
                P.Class("mb-2 mt-1 text-sm")[ex.Message],
                Button.Type("button").Class(Tw.BtnOutlineSecondary).Id("boom-recover").OnClick(recover)[UiIcon.Name(UiIconName.Undo).Class("me-1"), "Recover"]
            ]
        ];

    private static void ThrowFromHandler() =>
        throw new InvalidOperationException("kaboom — handler boundary demo");
}
Live result

Healthy subtree — click to throw.

A render-time throw is rewound cleanly (the serializer discards the partial output) and caught exactly once:

BoomRenderDemo.cs

namespace Rask.Site.Features;

// The same ErrorBoundary catches synchronous exceptions thrown inside a
// descendant's Render(). Clicking the button flips a flag; the next render of
// the child throws and the fallback replaces it.
public sealed partial class BoomRenderDemo : Component
{
    private bool _throwOnRender;

    protected override Component? Render() =>
        ErrorBoundary
            .Fallback(// Recover for the render-throw demo must ALSO reset _throwOnRender —
                      // otherwise the boundary clears its error, re-walks its cached Children
                      // (still containing the RenderThrower built last frame), and trips
                      // again on the same exception. Two cooperating dirty-marks are needed:
                      //   - recover()         → clears boundary._error, marks boundary dirty
                      //   - StateHasChanged() → marks THIS demo dirty so its Render re-
                      //                         executes with _throwOnRender=false and the
                      //                         boundary receives fresh Children without the
                      //                         RenderThrower.
                      // The handler-throw demo doesn't need this because the underlying
                      // state isn't stale across the trip.
            (ex, recover) => BoundaryFallback(ex, () =>
            {
                // Order matters: dirty-mark this demo FIRST so its re-render calls
                // the ErrorBoundary factory → boundary.SetProps with fresh Children
                // that no longer include the RenderThrower. THEN clear the boundary's
                // error. Calling recover() before that would synchronously re-render
                // the boundary against the stale cached Children (still containing
                // RenderThrower) — the boundary would trip again on the same
                // exception and the recovery would appear to do nothing.
                _throwOnRender = false;
                StateHasChanged();
                recover();
            }))[
            Div.Class("p-3 border rounded bg-white").Id("boom-render-host")[
                P.Class("text-ui-muted text-sm mb-2")["Healthy. Click below to make my next render throw."],
                Button.Type("button").Class(Tw.BtnWarning).Id("boom-render-trigger").OnClick(() => _throwOnRender = true)[UiIcon.Name(UiIconName.Bug).Class("me-2"), "Throw on next render"],
#pragma warning disable RASK014
                // Intentionally bypass the factory: RenderThrower is [SkipFactory] and
                // exists only to demonstrate that a descendant whose Render() throws is
                // caught by the enclosing ErrorBoundary.
                _throwOnRender ? new RenderThrower() : Text.Value(string.Empty)
#pragma warning restore RASK014
            ]
        ];

    private static Component BoundaryFallback(Exception ex, Action recover) =>
        Div.Class($"{Tw.AlertDanger} flex items-start").Id("boom-fallback")[
            UiIcon.Name(UiIconName.Warning).Class("me-3 size-6"),
            Div[
                Strong["Boundary caught: "],
                Code.Class("ms-1")[ex.GetType().Name],
                P.Class("mb-2 mt-1 text-sm")[ex.Message],
                Button.Type("button").Class(Tw.BtnOutlineSecondary).Id("boom-recover").OnClick(recover)[UiIcon.Name(UiIconName.Undo).Class("me-1"), "Recover"]
            ]
        ];

    // Trivial component whose Render always throws — used to demonstrate render-time
    // boundary capture. SkipFactory tells the source generator not to emit a public
    // factory; we instantiate it directly from inside the boundary's Children.
    [SkipFactory]
    private sealed class RenderThrower : Component
    {
        protected override Component? Render() =>
            throw new InvalidOperationException("kaboom — render-time boundary demo");
    }
}
Live result

Healthy. Click below to make my next render throw.

Nested boundaries — the innermost one catches, leaving its siblings untouched:

BoomNestedDemo.cs

namespace Rask.Site.Features;

// Boundaries nest: the inner boundary catches first, so the outer healthy
// region (and its sibling paragraph) stays mounted. If the inner fallback
// itself throws, the outer boundary catches the escalation.
public sealed partial class BoomNestedDemo : Component
{
    protected override Component? Render() =>
        ErrorBoundary.Fallback((ex, _) => OuterFallback(ex))[
            Div.Class("p-3 border rounded bg-white").Id("boom-nested-host")[
                P
                    .Class("mb-2 text-sm text-ui-muted")
                    .Id("boom-nested-outer-healthy")[
                    "Outer healthy region — stays mounted while the inner boundary trips."],
                ErrorBoundary.Fallback((ex, recover) => InnerFallback(ex, recover))[
                    Div.Class("p-3 border rounded bg-ui-well")[
                        P.Class("text-sm text-ui-muted mb-2")["Inner boundary subtree."],
                        Button.Type("button").Class(Tw.BtnDanger)
                            .Id("boom-nested-throw")
                            .OnClick(ThrowFromInnerHandler)[UiIcon.Name(UiIconName.Warning).Class("me-2"),
                            "Throw inside inner boundary"]
                    ]
                ]
            ]
        ];

    private static Component InnerFallback(Exception ex, Action recover) =>
        Div.Class($"{Tw.AlertWarning} flex items-start")
            .Id("boom-nested-inner-fallback")[
            UiIcon.Name(UiIconName.ShieldWarning).Class("me-3 size-6"),
            Div[
                Strong["Inner boundary caught: "],
                Code.Class("ms-1")[ex.GetType().Name],
                P.Class("mb-2 mt-1 text-sm")[ex.Message],
                Button.Type("button").Class(Tw.BtnOutlineSecondary)
                    .Id("boom-nested-inner-recover")
                    .OnClick(recover)[UiIcon.Name(UiIconName.Undo).Class("me-1"), "Recover inner"]
            ]
        ];

    private static Component OuterFallback(Exception ex) =>
        Div.Class(Tw.AlertDanger).Id("boom-nested-outer-fallback")[
            Strong["Outer boundary caught: "], ex.Message
        ];

    private static void ThrowFromInnerHandler() =>
        throw new InvalidOperationException("kaboom — inner boundary demo");
}
Live result

Outer healthy region — stays mounted while the inner boundary trips.

Inner boundary subtree.