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

Forms & validation

Rask binds inputs two-way with a strongly-typed Bind expression, validates each field by the rule written beside it — .Validate(…) — and tracks per-field state (touched / modified / messages / in-flight validation) on an EditContext. The same component code runs server-rendered or on WASM.

This guide builds up in layers: binding → forms → inline validation, sync and async → nested models → radio/checkbox groups. DataAnnotations and FluentValidation are supported too, for a model that already carries them.

For the analyzer IDs referenced here (RASK001, RASK022, …) see diagnostics.md.

On this page

  • Validation — inline .Validate(…) on a field or the form, async rules, rules in a value object; then DataAnnotations and FluentValidation.
  • Nested models & control groups — complex models, radio/checkbox groups, custom controls.

1. Two-way binding

The low-level path wires Value and an event handler yourself:

Input.Value(_typed).Type(InputType.Text).OnInput(v => _typed = v)
P[$"Echo: {_typed}"]

The low-level path: wire Value and the event handler yourself. Works for any input type, but you parse and re-render manually.

BindingManualDemo.cs
namespace Rask.Site.Features;

// Each live demo is its own user component so the bound input's auto-registered
// handler owner resolves to *this* demo (the structural CurrentParent at handler
// registration). Without this wrapper the owner falls back to CodeSample, which
// re-renders only itself and never re-evaluates the page's state.

public sealed partial class BindingManualDemo : Component
{
    private string _typed = "";

    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")[
            "Echo: ",
            Code[string.IsNullOrEmpty(_typed) ? "\"\"" : $"\"{_typed}\""]
        ]
    ];
}
Live result

Echo: ""

The ergonomic path is a Bind expression — one call replaces Value + OnInput + parsing:

Input.Bind(() => _model.Name).Live().Placeholder("Your name")
P[$"Hello, {_model.Name}!"]

.Live() is there because the page reads the name back while it is typed. A bound field that is only read when the form is saved does not need it — see bind timing.

Bind reads the expression — the property name becomes the input name and the property type picks the input type. A bound field waits for the next action. Live writes it as you type, so the greeting follows. One call replaces Value + OnInput + parsing.

BindingTypedDemo.cs
namespace Rask.Site.Features;

public sealed partial class BindingTypedDemo : Component
{
    private readonly Holder _model = new();

    protected override Component? Render() =>
    [
        Ui.Input.Bind(() => _model.Name).Live().Label("Your name")
            .Placeholder("Your name").Class("mb-2"),
        P.Class("text-sm mb-0")[
            "Hello, ",
            Strong[string.IsNullOrEmpty(_model.Name) ? "stranger" : _model.Name],
            "!"
        ]
    ];

    private sealed class Holder
    {
        public string Name { get; set; } = "";
    }
}
Live result

Hello, stranger!

Bind is an Expression<Func<TProp>>. The chain step reads the expression and derives everything from the bound property:

  • Input name ← the property name (name="Name"). Override with Name:.
  • Input type ← the property's CLR type (BindingHelpers.DefaultInputType): bool → checkbox, every numeric primitive → number, DateOnly → date, DateTime/DateTimeOffset → datetime-local, TimeOnly/TimeSpan → time, everything else → text. Override with Type: — an InputType enum value. The full set is Text, Search, Tel, Url, Email, Password, Number, Checkbox, Radio, File, Range, Color, Date, DatetimeLocal (renders datetime-local), Time, Week, Month, Hidden, Button, Submit, Reset, Image. The string-only family (Text/Search/Tel/Url/Email/ Password) only makes sense on an HTMLInputElement<string>; setting one on a non-string bound input is RASK025.
  • Passwords are the browser's. An input of type=password (or autocomplete current-password, new-password or one-time-code) sends what was typed to the model and never gets it back: the server writes "" to clear it and nothing else, so a model value set from the server does not reach the field, and a page that prefills a password is a bug in the page.
  • Update timing — a bound control says nothing until the next action, and its value travels with that: see bind timing.

Bind timing

A bound control waits for the next action. Typing into it, choosing in it and leaving it send nothing, run no rule and render nothing. When the reader presses a button, submits, or does anything else the page hears, everything they entered is sent first, in order, and then the action. This is how Livewire's wire:model works, and Flux's forms with it.

Three steps after Bind say otherwise:

Input.Bind(() => _m.Name)                                    // waits: the value travels with the next action
Input.Bind(() => _m.Title).Live()                            // as it is typed, after a 150 ms pause
Input.Bind(() => _m.Code).Live().Debounce(300.Milliseconds)  // a pause of your own
Input.Bind(() => _m.Notes).Blur()                            // on leaving the field
Select.Bind(() => _m.Country).Live()                         // as soon as it is picked
Step Livewire Writes the model and runs the rules
(none) wire:model with the next action
.Live() wire:model.live as the reader types, 150 ms after the last key; at once for a checkbox, a radio, a select, a range
.Debounce(300.Milliseconds) wire:model.live.debounce.300ms as the reader types, after that pause. Implies .Live(); TimeSpan.Zero sends every keystroke
.Blur() wire:model.live.blur (.blur before v4) on leaving the field

Say .Live() on a control you read while the reader is still in the form: a greeting that echoes a name, a character count, a total, a select that fills a second select, a checkbox that shows a section, a filter. A control you only read in the submit handler needs nothing.

  • An action is anything that reaches your code: a press on a button or a link with a handler, a submit, Enter in a field, a key an OnKeyDown hears, another control's .Live() or .Blur() change, a navigation, an event a kit component raises into your code (a tab with OnChange, a menu item, a dragged row). Each is preceded by the values that waited, so a handler never reads a model that has not heard what was typed.
  • A kit control drawing itself is not an action. Opening a Ui.Select, a Ui.Dropdown, a date or a time picker, moving its cursor with the arrows or the pointer, typing in its search field, paging a calendar: the page hears those only because it is what draws the control, and in Flux they are no request at all. Nothing that waits is written for them, so a rule of a field above the list does not speak when the list is opened. A pick in a bound control that waits is the same — the pick itself waits. What does count: a pick in a control that says .Live() or is held with Value/OnChange, a search field with an OnInput of yours, a Ui.Dropdown.OnToggle, a Ui.Tabs.OnChange, a "create" row — anything that reaches your code.
  • What the page does by itself is not an action. A toast whose time is up closing itself, a chart reporting the size it was measured at, a list scrolling, a video playing on, an animation ending, an image arriving, the reply to a JavaScript call, a call your script makes into .NET: none of them writes a field the reader is still typing in, and none runs its rules. Their handlers run; what waits goes on waiting for the reader.
  • A render the server starts — a timer, a push, a handler that finishes later — cannot ask the browser first. It sees the model as of the last action, and it does not take back what the reader has entered since: the text stays in the field, the box stays ticked, and both still travel with the next action.
  • Validation follows the value. A field that waits shows no message while it is typed into or left; its rules run when its value arrives, and all of them on submit. .Live(), .Debounce(…) and .Blur() fields validate when they write.
  • A message goes as soon as the reader starts correcting it — at the first key, and no rule runs for that. This is the one thing a waiting field sends while it is typed into: a message with no value in it, once, and only while a message shows.
  • The unsaved-changes guard (ConfirmLeave) counts the first keystroke, sent or not.
  • A rule still running when Save is pressed is not saved past: the submit runs every rule again and waits for the answers.
  • AfterBind runs when the value is written — with the action for a control that waits.

.Live() is on every bound control of Core: Input, Textarea, Select. .Debounce(…) and .Blur() are on Input, Textarea, Ui.Input and Ui.Textarea, for a field that is typed into — text, number, date. A checkbox, a radio, a range and a colour are chosen rather than typed: with either step they simply write as they are chosen. A file input's files travel with the submit, as in any form; OnFiles is an event of its own and is not bound. One thing defeats a pause, by sending the value early: an OnKeyDown or OnKeyUp on the field that hears every key. Give it data-rask-keys and it hears only the keys it acts on.

The kit's Ui.Input and Ui.Textarea forward all three steps to Core's.

Every chosen control of the kit follows the same rule, and .Live() is on each of them: Ui.Checkbox, Ui.Switch, Ui.Select (native, listbox, combobox, multiple), Ui.Pillbox, Ui.Autocomplete, Ui.CheckboxGroup, Ui.RadioGroup, Ui.Slider, Ui.Otp, Ui.Calendar, Ui.DatePicker, Ui.TimePicker, Ui.Editor, Ui.Tabs, Ui.MenuCheckbox and Ui.MenuRadioGroup.

Ui.Select.Bind(() => m.Plan).Listbox[…]          // the list shows the pick; the model hears it with the next action
Ui.Select.Bind(() => m.Country).Live()[…]        // written on pick: AfterBind and a dependent select react at once
Ui.Checkbox.Bind(() => m.Agreed).Live()          // a section that shows while it is ticked
Ui.DatePicker.Bind(() => m.From).Live()          // a price that follows the date
  • A control of one element — Ui.Checkbox, Ui.Switch, the native Ui.Select — waits in the browser, as an input does: ticking it sends nothing at all.
  • A control the kit draws in C# — a listbox, a calendar, a group, a slider — still sends its own event, because the server draws the pick: the list shows the option, the calendar the day. What waits is the write: the model, the member's rules and AfterBind hear the value with the next action, in the order the controls were last touched, before the action's own handler. Nothing the page wrote runs per pick, and the page is not rendered again for one — only the control.
  • What counts as the next action here is any handler that is not such a control drawing itself: a button of the page, a submit, another control's .Live() change, a controlled control's OnChange, and a callback the control itself raises into the page — Ui.Otp's OnComplete, a tab's OnClick, a select's OnInput — which finds the model written.
  • Two controls that wait do not write each other. A pick in the calendar leaves the listbox's pick waiting: a waiting control drawing itself is never an action, whichever control it is.
  • A listbox's pick is checked when it is written. A bound Ui.Select.Listbox used to run its member's rules on submit only; they now run when the value arrives, like any bound control's.
  • Not preceded: a callback that captures no component. The waiting picks are written ahead of a callback by way of the component that wrote it. A static lambda, or one that closes over locals only (.OnComplete(_ => row.Done = true) in a loop over rows), belongs to no component and runs without them; write it so that it touches the component (_ => Finish(row)).
  • A message under a control that waits goes when the reader picks again, as it goes under a field at the first key. The pick marks the field modified at once.
  • A control a render removes takes its unwritten pick with it. One that goes because the action itself replaces the page is written first. Following a link is not a handler of the page: it leaves the form, and a pick that was never written leaves with it — where a waiting input's text is still sent ahead of the navigation, because the browser holds it.
  • A pick that waits lives in the session. A reconnect to a new session — a restarted server — forgets it, where a waiting input keeps what was typed in the browser.

A controlled input is not bound and none of this applies to it: Input.Value(_filter).OnInput(v => _filter = v) hears every keystroke, as before.

The two modes are exclusive

A control's value comes from exactly one place, and the step you open the chain with says which:

Opened with Mode Its steps Compile, but do nothing here
.Bind(() => model.Field) bound Validate, AfterBind Checked, OnInput, OnChange
.Value(v) or .Of<T>() controlled Checked, OnInput, OnChange Validate, AfterBind

Bind and Value are the openings themselves, not steps you take later — taking one is what rules the other out, so neither appears again on the chain. Of<T>() opens a controlled chain for a control you are giving no value at all; if you want to supply one, that is .Value(v).

Everything else — Placeholder, Type, Required, Min/Max, OnFiles, the whole Class/Id/ Aria element surface — belongs to neither and is reachable from both.

Choosing a mode is enforced by the type: the two openings live on the control's entry and nowhere else, so taking one hands back the control and the other is not a member of it.

Input.Bind(() => _model.Name).Value(_typed)   // ✗ no such step: Bind already answered this

The steps that FOLLOW an opening are not gated, and that is a deliberate trade. Each mode's steps used to be declared only on that mode, which took a Bound/Controlled type argument threaded through every generated signature to arrange — for a rule that only ever caught a property being set and then not read. So this compiles now, and does nothing:

Input.Bind(() => _model.Name).OnInput(v => _log = v)   // compiles; the handler never runs

The reason it does nothing is that bound mode already owns those: it derives the rendered value (and a checkbox's checked) from the model and installs its own oninput/onchange write-back. Want a side effect on each bound write? That is what AfterBind is for.

The openings themselves stay exclusive: Input.Bind(…) and Input.Value(…) both live on the chain's entry, so once you have taken one the other is not there to take.

Beyond the constraint/affordance attributes shared with plain HTML (Min/Max/Step/Pattern/ MaxLength/MinLength/Multiple/Accept/List/Autocomplete/Autofocus), the core Input also carries the mobile & accessibility hints InputMode (on-screen keyboard), EnterKeyHint (action-key label), Spellcheck (the enumerated spellcheck="true|false"), Capture (camera/mic for a file input), and DirName. A control of your own forwards them the same way (see building-form-controls.md).

Fractional numbers get step="any" automatically. A decimal/double/float/Half binding renders <input type="number" step="any">. Without it HTML's default is step="1", so the browser's own constraint validation rejects 42.50 and refuses to fire submit — silently, with nothing thrown and no validation message, which reads as the form being broken. Integral types keep the implicit whole-number constraint. An explicit .Step(…) always wins, and is worth setting for money (.Step("0.01") makes the spinner step by cents).

File inputs

InputType.File turns an <input> into a file picker. Instead of binding a value, hand it an OnFiles callback (a synchronous or an asynchronous handler) that receives the selected IRaskFiles (Name/Size/ ContentType/OpenReadStream()), and constrain the picker with Accept, Multiple, and Capture:

Input.Of<string>().Type(InputType.File).Accept("image/*").Multiple()
     .OnFiles(async files => { foreach (var f in files) await Save(f); })

Uploading the bytes (streaming to a server endpoint, size limits, progress) is covered end-to-end in http-and-files.md.

Input.Bind(() => _model.Subscribe)   // bool     → checkbox
Input.Bind(() => _model.Age)         // int      → number
Input.Bind(() => _model.StartDate)   // DateOnly → date
Select.Bind(() => _model.Favorite)[Option.Value("Red")["Red"], Option.Value("Blue")["Blue"]]
Textarea.Bind(() => _model.Notes).Rows(3)

Those are the browser's own controls. An app normally writes the UI kit's, which bind the same way: Ui.DatePicker for a date, Ui.TimePicker for a time (the two side by side for a date and a time), Ui.Select.Bind(…).Listbox[…] for a choice and Ui.Input for text and numbers.

Bind is the same step on every control: a checkbox for a bool, an input for a number, the kit's date picker for a date and its listbox for a choice. Each says Live, so the readout follows it.

BindingMultiDemo.cs
namespace Rask.Site.Features;

public sealed partial class BindingMultiDemo : Component
{
    public enum Color { Red, Green, Blue }

    private static readonly (Color Value, string Text)[] Colors =
        [(Color.Red, "Red"), (Color.Green, "Green"), (Color.Blue, "Blue")];

    private readonly Holder _model = new();

    protected override Component? Render() =>
    [
        Div.Class("mb-3")[
            Ui.Checkbox.Bind(() => _model.Subscribe).Live().Id("bind-subscribe").Label("Subscribe to the newsletter")
        ],
        Div.Class("mb-3")[
            Ui.Input.Bind(() => _model.Age).Live().Label("Age")
                .Id("bind-age")
                .Min("0")
                .Max("120")
        ],
        Div.Class("mb-3")[
            Ui.DatePicker.Bind(() => _model.StartDate).Live().Label("Start date")
                .Id("bind-start")
        ],
        Div.Class("mb-3")[
            Ui.Select.Bind(() => _model.Favorite).Listbox.Live()
                .Label("Favourite colour")
                .Id("bind-favorite")[
                Colors.Select(color => Ui.SelectOption.Key(color.Text).Value(color.Value)[color.Text])
            ]
        ],
        Pre.Class("text-sm mb-0 p-3 bg-zinc-50 dark:bg-zinc-900 border rounded")[
            Code[
                $"Subscribe = {(_model.Subscribe ? "true" : "false")}\n" +
                $"Age       = {_model.Age}\n" +
                $"StartDate = {_model.StartDate:yyyy-MM-dd}\n" +
                $"Favorite  = {_model.Favorite}"
            ]
        ]
    ];

    public sealed class Holder
    {
        public bool Subscribe { get; set; }
        public int Age { get; set; } = 30;
        public DateOnly StartDate { get; set; } = new(2026, 1, 1);
        public Color Favorite { get; set; } = Color.Blue;
    }
}
Live result
January 2026
Su
Mo
Tu
We
Th
Fr
Sa
Red
Green
Blue
Subscribe = false
Age       = 30
StartDate = 2026-01-01
Favorite  = Blue

A Textarea binds as an input does. Live writes the model as you type, after a short pause, so the echo updates without blur or submit.

BindingTextareaDemo.cs
namespace Rask.Site.Features;

public sealed partial class BindingTextareaDemo : Component
{
    private readonly Holder _model = new();

    protected override Component? Render() =>
    [
        Ui.Textarea.Bind(() => _model.Notes).Live().Label("Jot something down…")
            .Id("bind-textarea")
            .Rows(3)
            .Placeholder("Jot something down…").Class("mb-2"),
        Pre.Class("text-sm mb-0 p-3 bg-zinc-50 dark:bg-zinc-900 border rounded")[
            Code[
                $"Notes  = \"{_model.Notes}\"\n" +
                $"Length = {_model.Notes.Length}"
            ]
        ]
    ];

    private sealed class Holder
    {
        public string Notes { get; set; } = "";
    }
}
Live result
Notes  = ""
Length = 0

Empty value handling

When the user clears an input, BindingHelpers.TrySetTyped decides what the empty string maps to:

Property kind Empty input becomes
Nullable<T> value type (int?, DateOnly?, …) null
NRT-nullable reference type (string?) null (detected via NullabilityInfoContext)
Non-nullable value type (int, DateOnly, enum) default(T) — so a number/date/enum input is clearable
Non-nullable string "" (verbatim)

A value that fails to parse ("1.5" or "99999999999" into an int, a date half typed) leaves the model unchanged and is not accepted in its place: the field says what it could not read ("Enter a whole number."), ahead of "required" and of any rule, and the submit handler does not run until a value arrives that fits — see what a number field says when it cannot read what was typed.

Controls over a value type bind its nullable too. Ui.Checkbox and Ui.Switch are over bool, Ui.Slider over a number and Ui.Calendar over DateOnly, and each also binds the nullable form: Ui.Checkbox.Bind(() => model.InStock) compiles whether InStock is a bool or a bool?, which is what a generated form model holds. A null draws as the control's empty state (unchecked, no day picked, a slider at its lowest value), and a change writes a value. Value and OnChange stay over the plain type. The date controls go one further: Ui.Calendar binds a DateOnly, and behind Flux's mode steps a collection of days (Ui.Calendar.Multiple.Bind(…)) or a UiDateRange (Ui.Calendar.Range.Bind(…)); Ui.DatePicker a DateOnly or, as Ui.DatePicker.Range, a UiDateRange; and Ui.TimePicker a TimeOnly?, a TimeOnly or a collection of times (UI kit).

Every BCL IParsable<T> type (numbers, Guid, DateOnly/DateTime/TimeOnly, bool, …) binds with no setup, and so does a custom IParsable<T> value type under the default interpreter build. For a full WASM AOT build, register your custom form-field types once at startup with RaskBinding.RegisterParsable<Money>() — custom route/query param types are registered automatically by the generator.

BindingNullableDemo.cs
using System.Globalization;

namespace Rask.Site.Features;

public sealed partial class BindingNullableDemo : Component
{
    public enum Color { Red, Green, Blue }

    private static readonly (Color? Value, string Text)[] Colors =
        [(null, "— none —"), (Color.Red, "Red"), (Color.Green, "Green"), (Color.Blue, "Blue")];

    private readonly Holder _model = new();

    protected override Component? Render() =>
    [
        Div.Class("mb-3")[
            Ui.Input.Bind(() => _model.OptionalAge).Live().Label("Optional age (int?)")
                .Id("bind-null-age")
                .Description("Leave it empty for null.")
        ],
        Div.Class("mb-3")[
            Ui.DatePicker.Bind(() => _model.StartDate).Live().Label("Optional start date (DateOnly?)")
                .Id("bind-null-start")
        ],
        Div.Class("mb-3")[
            // "— none —" is a real option rather than a Placeholder: choosing it clears the value back to null,
            // which is the point of this demo. A placeholder cannot be chosen.
            Ui.Select.Bind(() => _model.Favorite).Listbox.Live()
                .Label("Optional colour (Color?)")
                .Id("bind-null-color")[
                Colors.Select(color => Ui.SelectOption.Key(color.Text).Value(color.Value)[color.Text])
            ]
        ],
        Div.Class("mb-3")[
            Ui.Input.Bind(() => _model.Nickname).Live().Label("Nickname (string?)")
                .Id("bind-null-nick")
                .Description("Clear it for null.")
        ],
        Pre.Class("text-sm mb-0 p-3 bg-zinc-50 dark:bg-zinc-900 border rounded")[
            Code[
                $"OptionalAge = {_model.OptionalAge?.ToString(CultureInfo.InvariantCulture) ?? "null"}\n" +
                $"StartDate   = {_model.StartDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "null"}\n" +
                $"Favorite    = {_model.Favorite?.ToString() ?? "null"}\n" +
                $"Nickname    = {(_model.Nickname is null ? "null" : "\"" + _model.Nickname + "\"")}"
            ]
        ]
    ];

    public sealed class Holder
    {
        public int? OptionalAge { get; set; }
        public DateOnly? StartDate { get; set; }
        public Color? Favorite { get; set; }
        public string? Nickname { get; set; }
    }
}
Live result
Leave it empty for null.
October 2026
Su
Mo
Tu
We
Th
Fr
Sa
— none —
Red
Green
Blue
Clear it for null.
OptionalAge = null
StartDate   = null
Favorite    = null
Nickname    = null
BindingClearDefaultDemo.cs
using System.Globalization;

namespace Rask.Site.Features;

public sealed partial class BindingClearDefaultDemo : Component
{
    private readonly Holder _model = new();

    protected override Component? Render() =>
    [
        Div.Class("mb-3")[
            Ui.Input.Bind(() => _model.Age).Live().Label("Age (non-nullable int) — clear → 0")
                .Id("bind-clear-age")
        ],
        Div.Class("mb-3")[
            Ui.Input.Bind(() => _model.OptionalAge).Live().Label("Optional age (int?) — clear → null")
                .Id("bind-clear-optage")
                .Description("Leave it empty for null.")
        ],
        Pre.Class("text-sm mb-0 p-3 bg-zinc-50 dark:bg-zinc-900 border rounded")[
            Code.Id("bind-clear-echo")[
                $"Age         = {_model.Age}\n" +
                $"OptionalAge = {_model.OptionalAge?.ToString(CultureInfo.InvariantCulture) ?? "null"}"
            ]
        ]
    ];

    private sealed class Holder
    {
        public int Age { get; set; } = 30;
        public int? OptionalAge { get; set; } = 7;
    }
}
Live result
Leave it empty for null.
Age         = 30
OptionalAge = 7

Binding lifecycle

Each change handler runs in order: write the value, NotifyFieldChanged, run AfterBind (if supplied, only when a write actually happened), NotifyFieldTouched, then re-validate the field. When that happens is the control's bind timing: with the next action unless the chain says .Live(), .Debounce(…) or .Blur(). A text field sent at every keystroke (.Debounce(TimeSpan.Zero)) stays quiet until it is touched, then re-validates on every keystroke.

AfterBind fires after the new value is written and before validators run — handy for dependent fields (pick a country, repopulate the city dropdown in the same render):

Select.Bind(() => _model.Country)
    .AfterBind(c => { _cities = Cities[c]; _model.City = _cities[0]; })[ /* options */ ]
Select.Bind(() => _model.City)[_cities.Select(c => Option.Value(c)[c])]
BindingAfterBindDemo.cs
namespace Rask.Site.Features;

public sealed partial class BindingAfterBindDemo : Component
{
    private static readonly Dictionary<string, string[]> Cities = new(StringComparer.Ordinal)
    {
        ["US"] = new[] { "New York", "Los Angeles", "Chicago" },
        ["DE"] = new[] { "Berlin", "Hamburg", "Munich" },
        ["JP"] = new[] { "Tokyo", "Osaka", "Kyoto" }
    };

    private static readonly (string Value, string Text)[] Countries =
        [("US", "United States"), ("DE", "Germany"), ("JP", "Japan")];

    private readonly Holder _model = new();
    private string[] _cities = Cities["US"];

    protected override Component? Render() =>
    [
        Div.Class("mb-3")[
            Ui.Select.Bind(() => _model.Country).Listbox.Live()
                .Label("Country")
                .AfterBind(c =>
                {
                    _cities = Cities[c];
                    _model.City = _cities[0];
                })
                .Id("bind-after-country")[
                Countries.Select(country => Ui.SelectOption.Key(country.Value).Value(country.Value)[country.Text])
            ]
        ],
        Div.Class("mb-3")[
            Ui.Select.Bind(() => _model.City).Listbox.Live()
                .Label("City")
                .Id("bind-after-city")[
                _cities.Select(city => Ui.SelectOption.Key(city)[city])
            ]
        ],
        Pre.Class("text-sm mb-0 p-3 bg-zinc-50 dark:bg-zinc-900 border rounded")[
            Code.Id("bind-after-echo")[
                $"Country = {_model.Country}\n" +
                $"City    = {_model.City}"
            ]
        ]
    ];

    private sealed class Holder
    {
        public string Country { get; set; } = "US";
        public string City { get; set; } = "New York";
    }
}
Live result
United States
Germany
Japan
New York
Los Angeles
Chicago
Country = US
City    = New York

It takes an asynchronous handler just as readily — one property, either shape — and awaits before the post-handler render, so a dependent async lookup (repopulate a dropdown from an API) surfaces its loading state on its own, with no manual StateHasChanged():

BindingAfterBindAsyncDemo.cs
namespace Rask.Site.Features;

public sealed partial class BindingAfterBindAsyncDemo : Component
{
    private static readonly Dictionary<string, string[]> _catalog = new(StringComparer.Ordinal)
    {
        ["frontend"] = ["TypeScript", "JavaScript", "HTML", "CSS"],
        ["backend"] = ["C#", "Rust", "Go", "Python"],
        ["data"] = ["SQL", "Python", "R", "Scala"]
    };

    private static readonly (string? Value, string Text)[] Tracks =
        [("frontend", "Frontend"), ("backend", "Backend"), ("data", "Data")];

    private readonly Holder _model = new();
    private string[] _languages = [];
    private bool _loading;

    protected override Component? Render() =>
    [
        Div.Class("mb-3")[
            Ui.Select.Bind(() => _model.Track).Listbox.Live()
                // What the button says while Track is still null.
                .Placeholder("— pick a track —")
                .Label("Track")
                .AfterBind(async track =>
                {
                    // An unknown track clears the dependent list instead of throwing on _catalog[track].
                    if (track is null || !_catalog.TryGetValue(track, out var languages))
                    {
                        _languages = [];
                        _model.Language = null;
                        _loading = false;
                        return;
                    }

                    // Rask re-renders at every await suspension inside an async handler, so
                    // flipping _loading before the await below is enough to surface the
                    // "loading…" UI — a manual StateHasChanged() here would only set a deferred
                    // in-handler flag and push no frame.
                    _loading = true;
                    // Simulated remote fetch — swap for HttpClient.GetFromJsonAsync in real code.
                    // Pass the component's CancellationToken so unmount-during-fetch aborts
                    // the simulated work cleanly instead of mutating state on a stale instance.
                    try
                    {
                        await Task.Delay(300, CancellationToken);
                    }
                    catch (OperationCanceledException)
                    {
                        return;
                    }

                    _languages = languages;
                    _model.Language = _languages[0];
                    _loading = false;
                })
                .Id("bind-async-track")[
                Tracks.Select(track => Ui.SelectOption.Key(track.Text).Value(track.Value)[track.Text])
            ]
        ],
        Div.Class("mb-3")[
            Ui.Select.Bind(() => _model.Language).Listbox.Live()
                .Placeholder("— pick a track —")
                .Label(_loading ? "Language (loading…)" : "Language")
                .Id("bind-async-lang")
                .Disabled(_loading || _languages.Length == 0)[
                _languages.Select(language => Ui.SelectOption.Key(language)[language])
            ]
        ],
        Pre.Class("text-sm mb-0 p-3 bg-zinc-50 dark:bg-zinc-900 border rounded")[
            Code.Id("bind-async-echo")[
                $"Track    = {_model.Track}\n" +
                $"Language = {_model.Language}"
            ]
        ]
    ];

    public sealed class Holder
    {
        public string? Track { get; set; }
        public string? Language { get; set; }
    }
}
Live result
Frontend
Backend
Data
Track    = 
Language = 

What a bind costs, and the one case worth changing

Bind(() => …) takes an Expression<Func<T>>, and the C# compiler builds that tree at the call site on every render. Building it resolves a member token on the bound property's declaring type, and that cost scales with how many members the type has:

what you bind B/render
Input.Value(…) — controlled, no bind at all 1216
Input.Bind(() => Model.Name) — a plain model 3041
Input.Bind(() => Draft) — a property on the component 5011

The third row is the surprise, and it is invisible at the call site: the two spellings look equally cheap. A component is a markup host, so the generator injects the chain entries into it — around 430 members — and binding a property declared there pays for resolving a token on a type that large. Binding the same value on a plain model does not.

For nearly every app this does not matter. A form re-renders on interaction, not in a loop; a few kilobytes per render is well under the noise. Reach for the fix below only when a bound control is in something genuinely hot — a virtualized grid, a control that re-renders on every keystroke of a large document.

The fix is at the call site: hoist the expression into a field, so it is built once instead of per render.

using System.Linq.Expressions;

public sealed partial class Editor : Component
{
    private readonly Model _model = new();

    // Built once, in the constructor, rather than on every render.
    private readonly Expression<Func<string>> _name;

    public Editor() => _name = () => _model.Name;

    protected override Component Render() => Input.Bind(_name);
}

The two rows converge once the tree stops being rebuilt: 2721 B/render hoisted against a plain model, 2753 B hoisted against a property on the component — so hoisting does not merely help the expensive shape, it erases the difference. What remains is the binding machinery (FieldIdentifier, validator registration, owner tracking), which is shared with the plain-model case.

Every number on this page is pinned by BuilderEntryAllocationPinTests, so none of them can drift without a test going red.

The other move is simply to bind a plain model rather than a property on the component (() => _model.Name, not () => Draft), which is what most code does already and costs 3041 B rather than 5011 B without any ceremony.

Why not fix this in the framework? Measured and ruled out in #803: a Roslyn interceptor cannot help — an interceptor must keep the intercepted method's signature (CS9144), and the lambda is converted to an expression tree when the call is bound, before interception applies. Adding a Func<T> overload does not help either, because C# prefers the Expression one. What remains is making the injected chain entries inheritable instead, which is a change to how every markup host is generated.


2. Form.Model(…) and the EditContext

Form.Model(model) wraps the inputs and owns an EditContext — the per-field state store plus the validator pipeline. Bound inputs inside the form discover that context automatically.

Form.Model(_model).OnSubmit(m => Console.WriteLine(m.Username))[
    Input.Bind(() => _model.Username),
    Button.Type(ButtonType.Submit)["Sign up"]
]

A field's rule is a step on its input — the value in, the messages that reject it out:

Input.Bind(() => _model.Username)
    .Validate(v => v.Length >= 3 ? [] : ["At least 3 characters."])

A rule across fields goes on the form the same way, and either can be async: see validation.

Submit runs every rule, marks every registered field touched, then routes:

  • valid → OnSubmit (or, if unset, OnAnySubmit with the raw FormData),
  • invalid → OnInvalidSubmit.

OnSubmit / OnInvalidSubmit are one Callback<TModel> each, so either takes a synchronous (Action<TModel>) or an asynchronous (Func<TModel, Task>) handler — a bare lambda or a method group, no cast, no …Async twin.

While a submit is in flight the form takes no second one, as Livewire's wire:submit does: at the press, before anything is sent, the runtime disables the form's submit buttons (a <button form="…"> outside it too) and makes its text fields readonly, and it gives them back when the handler and the render it caused are done — on the Server host with that submit's ack, in a WebAssembly app when the dispatch returns. A double click, a second Enter or a script's requestSubmit() meanwhile is dropped in the browser and never sent, so a non-repeatable save (an upload, a payment) runs once. The fields stay in what is sent: readonly, unlike disabled, keeps a value in the form data. A render meanwhile does not hand the controls back early, and what it says of them (.Disabled(submitting) below) is what they are left with — except a field it shows a message under: a submit whose rule awaits a lookup draws what it already knows at that await, and the field it calls wrong is handed back then, so the first key of the correction is heard. The button's spinner still waits its 200 ms, so a fast save never flashes one. A plain button's OnClick is not held: Livewire disables nothing for wire:click.

Children that follow the submit

Children are normally a fixed list. Give the form a function instead and it is called on every render with whether a submit is in flight, so the markup can say so without the page tracking it:

Form.Model(_model).OnSubmit(Save)[submitting => [
    Input.Bind(() => _model.Username).Disabled(submitting),
    Button.Type(ButtonType.Submit).Disabled(submitting)[submitting ? "Saving…" : "Sign up"]
]]

The flag is true from the moment the submit handler starts until it returns — including when it throws — and the form re-renders on both edges. Only an async handler can be observed in that state: a synchronous one returns before there is a frame to paint.

FormSubmitStateDemo.cs
namespace Rask.Site.Features;

// Children as a FUNCTION of the submit state. `Form.Model(model)[submitting => [ … ]]` is called on
// every render with whether a submit is in flight, so the busy affordance — the disabled input, the
// button that reads "Saving…" — lives in the markup rather than in a bool this component maintains
// beside the model. The flag is raised when the handler starts and cleared when it returns, and the
// form re-renders on both edges, so only an `async` handler is observable in it.
public sealed partial class FormSubmitStateDemo : Component
{
    private readonly Model _model = new();
    private string _saved = "";

    protected override Component? Render() =>
        Div.Class("grid grid-cols-12 gap-4")[
            Div.Class("col-span-12 md:col-span-7")[
                Form.Model(_model).OnSubmit(Save).Id("fss-form")[f => [
                    Ui.Input.Bind(() => _model.Username).Label("Username")
                        .Disabled(f.Submitting)
                        .Id("fss-input").Class("mb-2"),
                    Ui.Button.Primary
                        .Submit
                        .Disabled(f.Submitting)
                        .Id("fss-submit")[f.Submitting ? "Saving…" : "Sign up"]
                ]]
            ],
            Div.Class("col-span-12 md:col-span-5")[
                P.Class("text-sm text-slate-500 dark:text-slate-400 mb-0").Id("fss-out")[
                    "Saved: ", Strong[_saved.Length == 0 ? "(nothing yet)" : _saved]
                ]
            ]
        ];

    // Slow on purpose: a synchronous handler returns before there is a frame to paint, so the busy
    // state would never be seen. This stands in for the round trip a real save makes.
    private async Task Save(Model m)
    {
        await Task.Delay(800).ConfigureAwait(false);
        _saved = m.Username;
    }

    private sealed class Model
    {
        public string Username { get; set; } = "";
    }
}
Live result

Saved: (nothing yet)

A handler that throws tells the reader by itself: an error toast, The save failed. Please try again., in the reader's language (RaskString.FormSaveFailed). Their values stay in the fields and the submit is free for another try. A form that draws its own messages with Validation.Summary says the sentence there and raises no toast. The exception's own text is never shown — a server app in Development adds its type and message — and it is logged as an error. The same value's Error holds it (f => [ f.Error is null ? null : … ]), for a page that wants to draw more than the toast.

To handle it yourself, catch it in the handler. What the handler catches never reaches the form, so there is no toast. A handler outside a form that throws gets a sentence of its own (a handler that fails):

async Task Save(SignUp model)
{
    try { await Accounts.Create(model); }
    catch (MailServerDownException) { Toast.Warning("Signed up. The welcome mail will follow."); }
}

A handler that names the fields it is about — the store refusing a duplicate — is shown under those fields and is not an error at all; a DataAnnotations ValidationException is treated the same way. A refusal that names no field shows its own sentence — in Validation.Summary, or as the error toast when the form has none — in place of The save failed. A cancelled save says nothing. See a save that fails.

The fixed-list forms are untouched and still bind exactly as before:

Form.Model(_model)[Input.Bind(() => _model.Username), Button.Type(ButtonType.Submit)["Sign up"]]

Only a form offers the function form. It is an indexer declared on Form itself, so Div[submitting => …] does not compile — there is no submit state behind a <div> to report. See Form.

Asking before an action

.Confirm("…") asks the reader before a click or a submit is sent — in the browser's own confirm dialog, or in Ui.Confirm when the layout has one. Livewire's wire:confirm. It is a step on every element, so it works on a plain Button, a kit Ui.Button, a Form and a submit button alike:

Ui.Button.Icon(Ui.IconName.Trash).OnClick(() => Delete(row)).Confirm("Delete the partner?")

// Livewire's wire:confirm.prompt: the reader types the name, and only an exact match goes (case and all).
Ui.Button.Danger.OnClick(() => Delete(row)).Confirm("Type the partner's name to delete it", expect: row.Name)

// On a form it asks before the submit; on a submit button, before the submit that button makes.
Form.Model(_order).OnSubmit(Send).Confirm("Send the order?")[ … ]
FormConfirmDemo.cs
namespace Rask.Site.Features;

// Asking before an action: `.Confirm("…")` asks before the click leaves the page, and `.Confirm("…", expect: name)`
// asks for the name to be typed. Cancelled, nothing is sent.
//
// The question is asked in the dialog this site's layout places once, a Ui.Confirm in ShowcaseLayout. An app
// without one gets the browser's own confirm and prompt.
public sealed partial class FormConfirmDemo : Component
{
    private readonly List<string> _partners = ["Acme", "Globex", "Initech"];

    protected override Component? Render() =>
        Div.Id("fc-demo")[
            Ul.Class("mb-2")[
                _partners.Select(name => Li.Key(name).Class("flex items-center gap-2 py-1")[
                    Span.Class("grow")[name],
                    Ui.Button.Icon(Ui.IconName.Trash).Id($"fc-delete-{name}")
                        .OnClick(() => _partners.Remove(name)).Confirm($"Delete {name}?")["Delete"],
                    Ui.Button.Danger.Id($"fc-wipe-{name}")
                        .OnClick(() => _partners.Remove(name)).Confirm($"Type {name} to delete it", expect: name)["Delete by name"]
                ])
            ],
            P.Class("text-sm text-slate-500 dark:text-slate-400 mb-0").Id("fc-left")[$"{_partners.Count} partners"]
        ];
}
Live result
  • Acme
  • Globex
  • Initech

3 partners

The question is asked in the browser, before anything leaves it, so it costs no round trip. Cancelled, nothing is sent: not the click or the submit, and not the fields still waiting to go with it — a bound field that waits for the next action keeps waiting. Both hosts ask the same way. The element carries the question as data-rask-confirm (and the expected answer as data-rask-confirm-expect), so it is in the page's own words and in its language. Do not put a secret in expect: — it is in the page.

Ask for a confirmation in a dialog of your own

The browser's confirm and prompt cannot be styled or translated. Put Ui.Confirm in the layout, once, and every .Confirm("…") under it asks in that dialog instead — a Ui.Modal drawn as Flux's confirmation: a short heading, the call site's question as the sentence beneath it, No and Yes.

Ui.Main[Outlet],
Ui.Toast,
Ui.ConfirmLeave,
Ui.Confirm.Heading("Megerősítés").No("Nem").Yes("Igen")   // "Confirm", "No" and "Yes" when unset

The call sites do not change. No, the close button, Escape and a press outside call the action off; only Yes lets it go — the same press, or the same submit by the same button, and a form is held only then. With expect: the dialog shows a field, and Yes waits until the answer is typed exactly, case and all; Enter in the field is Yes once it matches. It all happens in the browser: opening the dialog, typing in it and closing it send the server nothing, and focus goes back to the control that was pressed. A question can bring its own title, which replaces the layout's for that question alone:

Ui.Button.Danger.OnClick(() => Delete(row)).Confirm("Biztosan törli a céget?", heading: "Cég törlése")["Törlés"]

Without Ui.Confirm the browser asks, as before, and heading: is not shown. The words default from the string catalog (ConfirmHeading, ConfirmNo, ConfirmYes).

Ask before leaving unsaved changes

ConfirmLeave makes a form ask before the reader walks away from what they typed:

Form.Model(_model).OnSubmit(Save)
    .ConfirmLeave("Leave without saving your changes?")[
    Input.Bind(() => _model.Name),
    Button.Type(ButtonType.Submit)["Save"]
]
FormConfirmLeaveDemo.cs
namespace Rask.Site.Features;

// A form that asks before it is left unsaved. `ConfirmLeave` is the whole of it: from the first character
// typed, a link to another page, the Back button and closing the tab all ask first, and staying keeps the
// page exactly as it was. Saving ends it — until the next edit.
//
// The question is asked in the dialog this site's layout places once, a Ui.ConfirmLeave in ShowcaseLayout.
// An app without one gets the browser's own confirm.
public sealed partial class FormConfirmLeaveDemo : Component
{
    private readonly Model _model = new();
    private string _saved = "";

    protected override Component? Render() =>
        Div.Class("grid grid-cols-12 gap-4")[
            Div.Class("col-span-12 md:col-span-7")[
                Form.Model(_model).OnSubmit(Save)
                    .ConfirmLeave("Leave without saving your note?").Id("fcl-form")[
                    Ui.Input.Bind(() => _model.Note).Label("Note").Id("fcl-input").Class("mb-2"),
                    Ui.Button.Primary.Submit.Id("fcl-submit")["Save"]
                ]
            ],
            Div.Class("col-span-12 md:col-span-5")[
                P.Class("text-sm text-slate-500 dark:text-slate-400 mb-2").Id("fcl-out")[
                    "Saved: ", Strong[_saved.Length == 0 ? "(nothing yet)" : _saved]
                ],
                P.Class("text-sm text-slate-500 dark:text-slate-400 mb-0")[
                    "Type a note, then follow any link in the sidebar or press Back."
                ]
            ]
        ];

    private void Save(Model m) => _saved = m.Note;

    private sealed class Model
    {
        public string Note { get; set; } = "";
    }
}
Live result

Saved: (nothing yet)

Type a note, then follow any link in the sidebar or press Back.

The form counts as unsaved from the first thing typed or changed in it. That is decided in the browser, so it holds for a value that has not reached the server yet — and it is not a comparison with the original values: typing a value and typing it back still counts. While it is unsaved, every way out of the page asks first:

The reader… What asks
follows a link inside the app (NavLink, a Markdown link to another page) your message, in the browser's own confirm dialog — or in Ui.ConfirmLeave when the layout has one
is navigated by front-end code (__raskHost.navigate(…), an island's router) the same dialog
presses Back or Forward the same dialog; staying puts the address and the history back where they were
closes the tab, reloads, or follows a link out of the app the browser's own "leave site?" prompt. No page can set its text, so your message is not shown there.

Staying sends nothing to the server and leaves the page exactly as it was, typed values included.

The form is clean again after a submit that passed validation and whose handler returned — and unsaved again at the next edit. A submit validation refuses, or a handler that throws, leaves it unsaved. A navigation your own handler makes never asks, so the usual "save, then go to the list" needs nothing extra:

private async Task Save(Product product)
{
    await product.Save();
    Routes.ProductsPage().Go();   // not asked about: the app is leaving, not the reader
}

Ask in a dialog of your own

Put Ui.ConfirmLeave in the layout, once, and every guarded form under it asks in that dialog instead of the browser's confirm — a Ui.Modal drawn as Ui.Confirm is, with the form's message as its heading:

Ui.Main[Outlet],
Ui.Toast,
Ui.ConfirmLeave.Stay("Nem").Leave("Igen")   // the two buttons; "Stay" and "Leave" when unset
Ui.ConfirmLeave.Heading("Unsaved changes")  // a short title over the form's question, as Flux titles a confirmation

The forms do not change. Stay, the close button, Escape and a press outside the dialog all keep the reader on the page, as it was; Leave carries on with the navigation that was asked for — the same link, the same navigate(…), the same history entry Back was going to. The dialog is rendered closed and opened in the browser, so it needs no round trip to appear. Closing the tab, a reload and a link out of the app still get the browser's own prompt: no page can draw that one.

A form that is no longer on the page guards nothing. With several guarded forms on one page, the first unsaved one asks, with its own message.

Three things to know:

  • What counts as an edit is the browser's input and change events inside the form, and every value the reader chooses in a kit control that draws itself — a listbox, a combobox or a pillbox option, a pill taken away, a day in a Ui.Calendar or a Ui.DatePicker, a time, a colour, a rating, a picker's clear button, a Ui.FileItemRemove. The browser raises no event for those, so the page that hears the press says so on the form, bound with Bind or held with Value/OnChange alike, written to the model yet or not. Opening a list, moving through a calendar and typing in a list's search field change nothing and count for nothing. A value the page writes by itself is no edit, and neither is a button of your own whose OnClick sets a property.
  • A pick is counted once the page has heard it, which is a round trip after the press. A reader who picks and follows a link within that instant is not asked.
  • Which way Back went is read from the Navigation API. On a browser without it (Safari before 26.2, Firefox before 147) a refused Back or Forward still leaves the reader on the page with the right address, but as a new history entry — and Back and Forward ask with confirm there even when the layout has a Ui.ConfirmLeave, because the move cannot be made a second time.
  • A press in the first instant — before the script that guards the form has arrived — is not asked about. The guard is one of the behaviour hooks: a page rendered with the form loads it with the runtime, so this is a matter of milliseconds.

Auto-created vs explicit Context

By default the form creates (and caches per model reference) its own EditContext — it persists across renders of the same model, so field state survives re-renders. Pass Context: to own the instance yourself when you need to drive validation imperatively, register an IAsyncFieldValidator, or tune ValidatingStickyMs:

_ctx = new EditContext(_model);
_ctx.AddValidator(new SlowTitleValidator());

Form.Model(_model).OnSubmit(m => _submission = "Saved").Context(_ctx)[
    Input.Bind(() => _model.Title),
    Button.Type(ButtonType.Button).OnClick(() => _ctx.Validate().AsTask())["Validate now"],
    Button.Type(ButtonType.Submit).Disabled(_ctx.IsValidatingAny)["Save"]
]

Form requires either Model or Context — they are the two ways to open its chain.

Binding a data model

A Rask.Data entity gets a form shape generated beside it: ProductModel beside Product, every mapped property settable and the entity's DataAnnotations copied onto it. Bind one and the form validates by the entity's own rules, with nothing declared here:

private readonly ProductModel _product = new();

Form.Model(_product).OnSubmit(Create)[
    Input.Bind(() => _product.Name),
    Input.Bind(() => _product.Price),
    Button.Type(ButtonType.Submit)["Create"]
]

private async Task Create(ProductModel product)
{
    await using var db = await contexts.CreateDbContextAsync(CancellationToken);
    db.Add(Product.Create(product.Name, product.Price));
    await db.SaveChangesAsync(CancellationToken);
}

The model is what the writes on the type take — Product.Create(model), Product.Update(id, model) — or what a command carries to a handler. It carries no id, so the row an edit saves is the one the page's own route names, and it carries the row's Version back, so Update throws DbUpdateConcurrencyException when it lost a race rather than overwriting the other one. See a create and an edit form.

An edit page that reads its record

The page reads the record, in OnUpdated (it runs again when the route's id changes) or in OnMount, and draws the form only once the record is there:

[Route("/trailers/{Id}")]
public sealed partial class TrailerEditPage : Component
{
    private TrailerModel? _trailer;

    [RouteParam] public int Id { get; set; }

    protected override async Task OnUpdated() => _trailer = await Trailer.Model(Id);

    protected override Component? Render() =>
        _trailer is null
            ? Ui.Callout["No such trailer"]
            : TrailerForm.Key(Id).Model(_trailer);
}

No "read yet?" flag is needed beside the null check. The first GET and a navigation in place both wait for the read, so a read that is back within the budget is sent as one frame — the form with its record — and "No such trailer" is only ever seen for a trailer that does not exist. A read that outlasts the budget is painted as the null branch first and as the form when it lands; the page is drawn again when its hook's await completes, with nothing to call.

A read that throws shows the built-in error page in place of the page (the first GET answers 500). To say a sentence of your own instead, wrap the read in try/catch and draw it from Render.

A form is its model's form: Form.Model(model) keeps one EditContext per model instance, so handing it another instance — the next record under the same Key, a record read again — is a new form with that instance's values, and nothing typed into the previous one is left in its fields.

A form component reads its model where it draws the form. A component of your own that wraps the form takes the model as a prop and uses it in Render:

public sealed partial class TrailerForm : Component
{
    public TrailerModel? Model { get; set; }

    protected override Component? Render() =>
        Model is null ? null : Form.Model(Model).OnSubmit(Save)[Ui.Input.Bind(() => Model.Name).Label("Name")];
}

The trap is a copy: _model = Model; in the component's OnMount is taken once, when the component is mounted. If the page mounted it before its read was back — TrailerForm.Model(_trailer) with no null check around it — the copy is null and stays null however many times the page hands it the record afterwards. Read the prop in Render; where a copy is really wanted (a draft the reader may discard), take it in OnUpdated, which runs again whenever the prop changes, or mount the component only once the model is read, as the page above does.

Rendering messages

Two headless components read the context — both take a required Template step, written first, so you own the markup, and both render nothing when there's nothing to show:

Validation.Message.Template(errs => Div.Class("field-error")[errs[0]]).For(() => _model.Email)

Validation.Summary.Template(entries => Ul[entries.Select(e => Li[Strong[e.Field], ": ", e.Message])])

Validation.Message.For keys a single field; Validation.Summary lists every ValidationEntry (Field + Message), with form-level messages carrying an empty Field.

ValidationSummaryDemo.cs
using Rask.Core.Forms;

namespace Rask.Site.Features;

public sealed partial class ValidationSummaryDemo : Component
{
    private static readonly (string? Value, string Text)[] Plans = [("free", "Free"), ("pro", "Pro"), ("team", "Team")];

    private readonly RegistrationModel _model = new();
    private string? _submission;

    private static Component SummaryAlert(IReadOnlyList<ValidationEntry> entries) =>
        Ui.Callout.Danger.Icon(Ui.IconName.ExclamationTriangle).Role("alert")
            .Heading($"Please fix {entries.Count} error{(entries.Count == 1 ? "" : "s")}:")[Ui.CalloutText[Ul.Class("mb-0 ps-3")[
                entries.Select((e, i) => Li.Key(i)[
                    e.Field.Length == 0
                        ? e.Message
                        : [Strong[e.Field], ": ", e.Message]
                ])
            ]]];

    protected override Component? Render() =>
    [
        Form.Model(_model).OnSubmit(m => _submission = $"Registered: {m.Name} <{m.Email}>").Class("flex flex-col gap-3")[
            Validation.Summary.Template(SummaryAlert),
            // ShowValidation(false) on every field: the errors belong to the summary above, which is what this
            // demo is showing, and a field that also said its own would say each one twice.
            Div[
                Ui.Input.Bind(() => _model.Name).Label("Name").Id("v2-name").ShowValidation(false)
            ],
            Div[
                Ui.Input.Bind(() => _model.Email).Label("Email")
                    .Id("v2-email")
                    .Type(InputType.Email)
                    .ShowValidation(false)
            ],
            Div[
                Ui.Input.Bind(() => _model.Age).Label("Age").Id("v2-age").ShowValidation(false)
            ],
            Div[
                Ui.Select.Bind(() => _model.Plan).Listbox
                    .Placeholder("— choose —")
                    .Label("Plan")
                    .Id("v2-plan")
                    .ShowValidation(false)[
                    Plans.Select(plan => Ui.SelectOption.Key(plan.Text).Value(plan.Value)[plan.Text])
                ]
            ],
            Div[
                Ui.Button.Primary.Icon(Ui.IconName.CheckCircle).Submit["Register"]
            ]
        ],
        _submission is null
            ? null
            : Ui.Callout.Success.Icon(Ui.IconName.CheckCircle).Class("mt-3").Role("status").Text(_submission)
    ];
}
Live result
Free
Pro
Team

Controls at a glance

Every input works in two shapes — controlled (Value + OnChange, the parent owns the value) and bound (.Bind(() => model.X), two-way). A readout rendered outside the control follows a controlled one at every change, and a bound one that says .Live(). The matrix below covers text, textarea and select; the UI kit's controls take the same two shapes, since they implement the same IFormControl<T>.

FormControlsInputDemo.cs
namespace Rask.Site.Features;

// UiInput<T> — Rask.Core's HTMLInputElement<T> underneath — in both shapes side by side.
//   • Controlled — Value + OnChange: the parent owns the text; OnChange fires on commit (blur/Enter) and
//     re-renders this consumer so the "Echo:" readout updates (the controlled-OnChange fix).
//   • Bound — Ui.Input.Bind(() => model.X): two-way binds through the EditContext. A bound field waits for
//     the next action; Live() writes the model as it is typed, so the "Echo:" readout follows.
public sealed partial class FormControlsInputDemo : Component
{
    private string _controlled = "";
    private readonly Model _model = new();

    protected override Component? Render() =>
        Div.Class("grid grid-cols-12 gap-4")[
            Div.Class("col-span-12 md:col-span-6")[
                Ui.Input.Value(_controlled).Label("Controlled (Value + OnChange)")
                    .OnChange(v => _controlled = v)
                    .Description("Type, then leave the field — OnChange fires on commit.")
                    .Id("fc-input-controlled").Class("mb-2"),
                P.Class("text-sm text-zinc-500 dark:text-white/70 mb-0").Id("fc-input-controlled-out")[
                    "Echo: ", Strong[_controlled.Length == 0 ? "(empty)" : _controlled]
                ]
            ],
            Div.Class("col-span-12 md:col-span-6")[
                Form.Model(_model)[
                    Ui.Input.Bind(() => _model.Text).Live().Label("Bound (two-way)")
                        .Id("fc-input-bound").Class("mb-2")
                ],
                P.Class("text-sm text-zinc-500 dark:text-white/70 mb-0").Id("fc-input-bound-out")[
                    "Echo: ", Strong[_model.Text.Length == 0 ? "(empty)" : _model.Text]
                ]
            ]
        ];

    private sealed class Model
    {
        public string Text { get; set; } = "";
    }
}
Live result
Type, then leave the field — OnChange fires on commit.

Echo: (empty)

Echo: (empty)

FormControlsTextareaDemo.cs
namespace Rask.Site.Features;

// UiTextarea<T> — Rask.Core's HTMLTextAreaElement<T> underneath — in both shapes side by side.
//   • Controlled — Value + OnChange: the parent owns the text; OnChange fires on commit (blur) and
//     re-renders this consumer so the character-count readout updates (the controlled-OnChange fix).
//   • Bound — Ui.Textarea.Bind(() => model.X): two-way binds through the EditContext. A bound field waits
//     for the next action; Live() writes the model as it is typed, so the character count follows.
public sealed partial class FormControlsTextareaDemo : Component
{
    private string _controlled = "";
    private readonly Model _model = new();

    protected override Component? Render() =>
        Div.Class("grid grid-cols-12 gap-4")[
            Div.Class("col-span-12 md:col-span-6")[
                Ui.Textarea.Value(_controlled).Label("Controlled (Value + OnChange)")
                    .OnChange(v => _controlled = v)
                    .Rows(3)
                    .Description("Type, then leave the field — OnChange fires on commit.")
                    .Id("fc-textarea-controlled").Class("mb-2"),
                P.Class("text-sm text-zinc-500 dark:text-white/70 mb-0").Id("fc-textarea-controlled-out")[
                    "Length: ", Strong[_controlled.Length]
                ]
            ],
            Div.Class("col-span-12 md:col-span-6")[
                Form.Model(_model)[
                    Ui.Textarea.Bind(() => _model.Bio).Live().Label("Bound (two-way)")
                        .Rows(3)
                        .Id("fc-textarea-bound").Class("mb-2")
                ],
                P.Class("text-sm text-zinc-500 dark:text-white/70 mb-0").Id("fc-textarea-bound-out")[
                    "Length: ", Strong[_model.Bio.Length]
                ]
            ]
        ];

    private sealed class Model
    {
        public string Bio { get; set; } = "";
    }
}
Live result
Type, then leave the field — OnChange fires on commit.

Length: 0

Length: 0

FormControlsSelectDemo.cs
namespace Rask.Site.Features;

// Ui.Select, as Flux's listbox, in both shapes side by side.
//   • Controlled — Value + OnChange: the parent owns the value in a field; OnChange writes it back and
//     re-renders this consumer, so the "Picked:" readout updates live (the controlled-OnChange fix).
//   • Bound — Ui.Select.Bind(() => model.X): two-way binds the model property through the ambient EditContext.
// Both readouts refresh on every change with no StateHasChanged.
public sealed partial class FormControlsSelectDemo : Component
{
    private static readonly (string Value, string Text)[] Frameworks =
        [("Rask", "Rask"), ("Blazor", "Blazor"), ("htmx", "htmx")];

    private string _controlled = "Rask";
    private readonly Model _model = new();

    protected override Component? Render() =>
        Div.Class("grid grid-cols-12 gap-4")[
            Div.Class("col-span-12 md:col-span-6")[
                Ui.Select
                    .Value(_controlled)
                    .Listbox
                    .Label("Controlled (Value + OnChange)")
                    .OnChange(v => _controlled = v)
                    .Id("fc-select-controlled")
                    .Class("mb-2")[
                    Frameworks.Select(framework => Ui.SelectOption.Key("controlled-" + framework.Value).Value(framework.Value)[framework.Text])
                ],
                P.Class("text-sm text-zinc-500 dark:text-white/70 mb-0").Id("fc-select-controlled-out")[
                    "Picked: ", Strong[_controlled]
                ]
            ],
            Div.Class("col-span-12 md:col-span-6")[
                Form.Model(_model)[
                    Ui.Select.Bind(() => _model.Framework).Listbox.Live().Label("Bound (two-way)").Id("fc-select-bound").Class("mb-2")[
                        Frameworks.Select(framework => Ui.SelectOption.Key("bound-" + framework.Value).Value(framework.Value)[framework.Text])
                    ]
                ],
                P.Class("text-sm text-zinc-500 dark:text-white/70 mb-0").Id("fc-select-bound-out")[
                    "Picked: ", Strong[_model.Framework]
                ]
            ]
        ];

    private sealed class Model
    {
        public string Framework { get; set; } = "Rask";
    }
}
Live result
Rask
Blazor
htmx

Picked: Rask

Rask
Blazor
htmx

Picked: Rask

Labels. Ui.Input, Ui.Textarea and Ui.Select are Flux UI's: Label draws a label over the field, Description help text under the label, and each bound field shows its own validation message under the control:

using System.ComponentModel.DataAnnotations;

namespace Rask.Site.Features;

public sealed partial class FloatingLabelsDemo : Component
{
    private static readonly (string? Value, string Text)[] Plans = [("free", "Free"), ("pro", "Pro"), ("team", "Team")];

    private readonly AccountModel _model = new();
    private string? _submission;

    protected override Component? Render() =>
    [
        Form.Model(_model).OnSubmit(m => _submission = $"Created account for {m.FullName} <{m.Email}>").Class("flex flex-col gap-2")[
            // One line per field. A labelled kit text field floats its label by default: the caption sits in
            // the field until there is content, then rises out of the way. The label is the field's real
            // <label>, linked to the control, and each bound field shows its own validation message, fed by
            // the [Required]/[Range]/etc. attributes through the built-in DataAnnotations pass. Every property
            // is nullable — Rask clears to null.
            Ui.Input.Bind(() => _model.FullName).Label("Full name").Id("ff-FullName"),
            Ui.Input.Bind(() => _model.Email).Label("Email address").Type(InputType.Email).Id("ff-Email"),
            Ui.Input.Bind(() => _model.Age).Label("Age").Id("ff-Age"),
            Ui.Select.Bind(() => _model.Plan).Listbox.Label("Plan").Placeholder("— choose —").Id("ff-Plan")[
                Plans.Select(plan => Ui.SelectOption.Key(plan.Text).Value(plan.Value)[plan.Text])
            ],
            Ui.Textarea.Bind(() => _model.Bio).Label("Short bio").Id("ff-Bio"),
            Div.Class("mt-1")[
                Ui.Button.Primary.Icon(Ui.IconName.UserPlus).Submit["Create account"]
            ]
        ],
        _submission is null
            ? null
            : Ui.Callout.Success.Icon(Ui.IconName.CheckCircle).Class("mt-3").Role("status").Text(_submission)
    ];
}
Live result
Free
Pro
Team

Accessible validation

A control of your own (see building form controls), and the UI kit's controls) expose validation to assistive tech automatically — no extra props. When a bound field has messages, the control renders aria-invalid="true" and an aria-describedby that points at the error message's id (and the description's id when .Description(…) is set), so a screen reader reads the error with the field rather than detached from it. Valid fields with a Description still get aria-describedby to it.

Composing the field yourself. The kit's Ui.Field is the same three parts as components — Flux UI's field — for when the label, the help text or the message needs to sit somewhere a control's own Label would not put it:

Ui.Field[
    Ui.Label.Badge("Required")["Email"],
    Ui.Input.Bind(() => _model.Email).ShowValidation(false),
    Ui.Error,                                   // or Ui.Error.For(() => _model.Email) outside a field
    Ui.Description["We only write about your order."]
]

Ui.Label is a <label for> pointing at the control, and Ui.Error is a role="alert" live region that is always in the page and shows the first message the form holds for the bound member. Ui.Fieldset.Legend("Shipping address")[…] groups several fields under one heading.

A combobox control — Ui.Select with .Listbox or .Combobox, over one answer or many — carries role="combobox" on a real <button> or text <input>. Both are labelable elements, so the select's Label names it through the field's <label for>, as it does the native <select>. Alongside it goes the popup contract: aria-haspopup="listbox", aria-expanded, aria-controls naming the list, and aria-activedescendant naming the option the keyboard cursor is on while focus stays on the box. Options are role="option" carrying aria-selected, and an unavailable one carries aria-disabled — present only when it is true, since a valueless aria-disabled reads as true and would mark every option unavailable.

A list that takes more than one answer says so on the listbox itself, with aria-multiselectable="true". Without it a reader meets options that each carry aria-selected and has no way to learn that a second one is allowed — the options look identical either way, so the fact that several may be chosen lives nowhere else.

If you build your own control from the core Input/Validation.Message primitives (§9), mirror the same three attributes so the field stays accessible: aria-invalid on the control, aria-describedby from the control to the message id, and role="alert" on the message container. See accessibility.md.

Confirm