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.
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}\""]
]
];
}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.
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; } = "";
}
}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 withName:. - 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 withType:— anInputTypeenum value. The full set isText,Search,Tel,Url,Email,Password,Number,Checkbox,Radio,File,Range,Color,Date,DatetimeLocal(rendersdatetime-local),Time,Week,Month,Hidden,Button,Submit,Reset,Image. The string-only family (Text/Search/Tel/Url/Email/Password) only makes sense on anHTMLInputElement<string>; setting one on a non-string bound input is RASK025. - Passwords are the browser's. An input of
type=password(orautocompletecurrent-password,new-passwordorone-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
OnKeyDownhears, another control's.Live()or.Blur()change, a navigation, an event a kit component raises into your code (a tab withOnChange, 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, aUi.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 withValue/OnChange, a search field with anOnInputof yours, aUi.Dropdown.OnToggle, aUi.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.
AfterBindruns 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 nativeUi.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
AfterBindhear 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'sOnChange, and a callback the control itself raises into the page —Ui.Otp'sOnComplete, a tab'sOnClick, a select'sOnInput— 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.Listboxused 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
staticlambda, 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. Adecimal/double/float/Halfbinding renders<input type="number" step="any">. Without it HTML's default isstep="1", so the browser's own constraint validation rejects42.50and 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.
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;
}
}Subscribe = false
Age = 30
StartDate = 2026-01-01
Favorite = BlueA 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.
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; } = "";
}
}Notes = ""
Length = 0Empty 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.
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; }
}
}OptionalAge = null
StartDate = null
Favorite = null
Nickname = nullusing 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;
}
}Age = 30
OptionalAge = 7Binding 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])]
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";
}
}Country = US
City = New YorkIt 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():
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; }
}
}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 theExpressionone. 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,OnAnySubmitwith the rawFormData), - 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.
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; } = "";
}
}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?")[ … ]
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"]
];
}- 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"]
]
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; } = "";
}
}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
inputandchangeevents 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 aUi.Calendaror aUi.DatePicker, a time, a colour, a rating, a picker's clear button, aUi.FileItemRemove. The browser raises no event for those, so the page that hears the press says so on the form, bound withBindor held withValue/OnChangealike, 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 whoseOnClicksets 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
confirmthere even when the layout has aUi.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.
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)
];
}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>.
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; } = "";
}
}Echo: (empty)
Echo: (empty)
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; } = "";
}
}Length: 0
Length: 0
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";
}
}Picked: Rask
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)
];
}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.