Forms & validation
Rask binds inputs two-way with a strongly-typed Bind expression, routes submit through
validators you opt into, 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 → DataAnnotations → FluentValidation → async → nested models → radio/checkbox groups.
For the analyzer IDs referenced here (RASK001, RASK022, …) see diagnostics.md.
On this page
- Validation — inline, DataAnnotations, FluentValidation, async.
- 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() =>
[
Input
.Value(_typed)
.Type(InputType.Text)
.Class($"{Tw.Input} mb-2")
.Placeholder("Type something")
.OnInput(v => _typed = v),
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).Placeholder("Your name")
P[$"Hello, {_model.Name}!"]
Bind reads the expression — the property name becomes the input name, the property type picks the input type, and string fields update on every keystroke. 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() =>
[
Input.Bind(() => _model.Name)
.Class($"{Tw.Input} mb-2")
.Placeholder("Your name"),
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>> (Input.Bound<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 anInput<string>; setting one on a non-string bound input is RASK025. - Update timing —
stringfields update on every keystroke (OnInput); every other type updates onOnChange(blur).Textarea(() => …)always streams onOnInput.
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 | Then adds | Does not offer |
|---|---|---|---|
.Bind(() => model.Field) |
bound | Validate / ValidateAsync, AfterBind / AfterBindAsync |
Checked, OnInput, OnChange |
.Value(v) or .Of<T>() |
controlled | Checked, OnInput / OnInputAsync, OnChange / OnChangeAsync |
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.
This is enforced by the type, not by a convention: the chain is a
Build<TControl, Bound> or a Build<TControl, Controlled>, and a step from the other mode is not
offered in completion and does not compile.
Input.Bind(() => _model.Name).OnInput(v => _log = v) // ✗ no such step on a bound chain
Input.Value(_typed).AfterBind(v => Save(v)) // ✗ no such step on a controlled chain
The reason 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. Setting OnInput
alongside Bind used to compile and then be dropped at render time, silently. Want a side effect on
each bound write? That is what AfterBind is for.
The generated factories carry the same split — Input(() => m.Name, OnInput: …) has no such
parameter — so neither surface can express a mode it will not honour.
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 explicitStep: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 (or OnFilesAsync) callback that receives the selected RaskFiles (Name/Size/
ContentType/OpenReadStream()), and constrain the picker with Accept, Multiple, and Capture:
Input<string>().Type(InputType.File).Accept("image/*").Multiple(true)
.FilesAsync(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("Red")["Red"], Option("Blue")["Blue"]]
Textarea.Bind(() => _model.Notes).Rows(3)
The same Bind helper picks the right input type from the property's CLR type and wires immediate (string) or change-deferred (everything else) update timing.
namespace Rask.Site.Features;
public sealed partial class BindingMultiDemo : Component
{
public enum Color { Red, Green, Blue }
private readonly Holder _model = new();
protected override Component? Render() =>
[
Div.Class("mb-3 flex items-center gap-2")[
Input.Bind(() => _model.Subscribe)
.Id("bind-subscribe")
.Class(Tw.CheckInput),
Label.For("bind-subscribe").Class($"{Tw.CheckLabel} ms-1")["Subscribe to the newsletter"]
],
Div.Class("mb-3")[
Label.For("bind-age").Class($"{Tw.Label} text-sm")["Age"],
Input.Bind(() => _model.Age)
.Id("bind-age")
.Class(Tw.Input)
.Min("0")
.Max("120")
],
Div.Class("mb-3")[
Label.For("bind-start").Class($"{Tw.Label} text-sm")["Start date"],
Input.Bind(() => _model.StartDate)
.Id("bind-start")
.Class(Tw.Input)
],
Div.Class("mb-3")[
Label.For("bind-favorite").Class($"{Tw.Label} text-sm")["Favourite colour"],
Select.Bind(() => _model.Favorite)
.Id("bind-favorite")
.Class(Tw.Select)[
Option.Value("Red")["Red"],
Option.Value("Green")["Green"],
Option.Value("Blue")["Blue"]
]
],
Pre.Class("text-sm mb-0 p-3 bg-ui-well border rounded")[
Code[
$"Subscribe = {(_model.Subscribe ? "true" : "false")}\n" +
$"Age = {_model.Age}\n" +
$"StartDate = {_model.StartDate:yyyy-MM-dd}\n" +
$"Favorite = {_model.Favorite}"
]
]
];
private 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 = BlueTextareas always stream — Textarea.Bound wires OnInputAsync for every keystroke 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() =>
[
Textarea.Bind(() => _model.Notes)
.Id("bind-textarea")
.Class($"{Tw.Input} mb-2")
.Rows(3)
.Placeholder("Jot something down…"),
Pre.Class("text-sm mb-0 p-3 bg-ui-well 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 ("not-a-number" into an int) leaves the model unchanged.
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.
namespace Rask.Site.Features;
public sealed partial class BindingNullableDemo : Component
{
public enum Color { Red, Green, Blue }
private readonly Holder _model = new();
protected override Component? Render() =>
[
Div.Class("mb-3")[
Label.For("bind-null-age").Class($"{Tw.Label} text-sm")["Optional age (int?)"],
Input.Bind(() => _model.OptionalAge)
.Id("bind-null-age")
.Class(Tw.Input)
.Placeholder("leave empty for null")
],
Div.Class("mb-3")[
Label.For("bind-null-start").Class($"{Tw.Label} text-sm")["Optional start date (DateOnly?)"],
Input.Bind(() => _model.StartDate)
.Id("bind-null-start")
.Class(Tw.Input)
],
Div.Class("mb-3")[
Label.For("bind-null-color").Class($"{Tw.Label} text-sm")["Optional colour (Color?)"],
Select.Bind(() => _model.Favorite)
.Id("bind-null-color")
.Class(Tw.Select)[
Option.Value("")["— none —"], Option.Value("Red")["Red"], Option.Value("Green")["Green"], Option.Value("Blue")["Blue"]
]
],
Div.Class("mb-3")[
Label.For("bind-null-nick").Class($"{Tw.Label} text-sm")["Nickname (string?)"],
Input.Bind(() => _model.Nickname)
.Id("bind-null-nick")
.Class(Tw.Input)
.Placeholder("clear me for null")
],
Pre.Class("text-sm mb-0 p-3 bg-ui-well border rounded")[
Code[
$"OptionalAge = {_model.OptionalAge?.ToString() ?? "null"}\n" +
$"StartDate = {_model.StartDate?.ToString("yyyy-MM-dd") ?? "null"}\n" +
$"Favorite = {_model.Favorite?.ToString() ?? "null"}\n" +
$"Nickname = {(_model.Nickname is null ? "null" : "\"" + _model.Nickname + "\"")}"
]
]
];
private 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 = null
namespace Rask.Site.Features;
public sealed partial class BindingClearDefaultDemo : Component
{
private readonly Holder _model = new();
protected override Component? Render() =>
[
Div.Class("mb-3")[
Label.For("bind-clear-age").Class($"{Tw.Label} text-sm")["Age (non-nullable int) — clear → 0"],
Input.Bind(() => _model.Age)
.Id("bind-clear-age")
.Class(Tw.Input)
],
Div.Class("mb-3")[
Label.For("bind-clear-optage").Class($"{Tw.Label} text-sm")["Optional age (int?) — clear → null"],
Input.Bind(() => _model.OptionalAge)
.Id("bind-clear-optage")
.Class(Tw.Input)
.Placeholder("leave empty for null")
],
Pre.Class("text-sm mb-0 p-3 bg-ui-well border rounded")[
Code.Id("bind-clear-echo")[
$"Age = {_model.Age}\n" +
$"OptionalAge = {_model.OptionalAge?.ToString() ?? "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/
AfterBindAsync (if supplied, only when a write actually happened), NotifyFieldTouched (on
change/blur), then re-validate the field. string inputs stay quiet until the field is touched,
then re-validate on every keystroke so a correction clears the message without a blur.
AfterBind / AfterBindAsync fire 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()
{
["US"] = new[] { "New York", "Los Angeles", "Chicago" },
["DE"] = new[] { "Berlin", "Hamburg", "Munich" },
["JP"] = new[] { "Tokyo", "Osaka", "Kyoto" }
};
private readonly Holder _model = new();
private string[] _cities = Cities["US"];
protected override Component? Render() =>
[
Div.Class("mb-3")[
Label.For("bind-after-country").Class($"{Tw.Label} text-sm")["Country"],
Select.Bind(() => _model.Country)
.AfterBind(c =>
{
_cities = Cities[c];
_model.City = _cities[0];
})
.Id("bind-after-country")
.Class(Tw.Select)[
Option.Value("US")["United States"],
Option.Value("DE")["Germany"],
Option.Value("JP")["Japan"]
]
],
Div.Class("mb-3")[
Label.For("bind-after-city").Class($"{Tw.Label} text-sm")["City"],
Select.Bind(() => _model.City)
.Id("bind-after-city")
.Class(Tw.Select)[
_cities.Select(c => Option.Value(c).Key(c)[c])
]
],
Pre.Class("text-sm mb-0 p-3 bg-ui-well 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 YorkAfterBindAsync awaits before the post-handler render, so a dependent async lookup (repopulate a
dropdown from an API) surfaces its loading state on its own — no manual StateHasChanged():
namespace Rask.Site.Features;
public sealed partial class BindingAfterBindAsyncDemo : Component
{
private static readonly Dictionary<string, string[]> _catalog = new()
{
["frontend"] = ["TypeScript", "JavaScript", "HTML", "CSS"],
["backend"] = ["C#", "Rust", "Go", "Python"],
["data"] = ["SQL", "Python", "R", "Scala"]
};
private readonly Holder _model = new();
private string[] _languages = [];
private bool _loading;
protected override Component? Render() =>
[
Div.Class("mb-3")[
Label.For("bind-async-track").Class($"{Tw.Label} text-sm")["Track"],
Select.Bind(() => _model.Track)
.AfterBindAsync(async track =>
{
// Re-selecting the placeholder (or any unknown track) clears the
// dependent list instead of throwing on _catalog[track].
if (!_catalog.ContainsKey(track))
{
_languages = [];
_model.Language = "";
_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 = _catalog[track];
_model.Language = _languages[0];
_loading = false;
})
.Id("bind-async-track")
.Class(Tw.Select)[
// Placeholder matching the empty initial Track. Without it the <select>
// visually defaults to "Frontend" while the model is still "" — and
// re-picking the already-shown first option fires no change event, so the
// async load never triggers. A selected placeholder keeps the initial
// display honest and makes every track pick a real change.
Option.Value("")["— pick a track —"],
Option.Value("frontend")["Frontend"],
Option.Value("backend")["Backend"],
Option.Value("data")["Data"]
]
],
Div.Class("mb-3")[
Label.For("bind-async-lang").Class($"{Tw.Label} text-sm")[
_loading ? "Language (loading…)" : "Language"
],
Select.Bind(() => _model.Language)
.Id("bind-async-lang")
.Class(Tw.Select)
.Disabled(_loading || _languages.Length == 0)[
_languages.Length == 0
? [Option.Value("")["— pick a track —"]]
: _languages.Select(l => Option.Value(l).Key(l)[l])
]
],
Pre.Class("text-sm mb-0 p-3 bg-ui-well border rounded")[
Code.Id("bind-async-echo")[
$"Track = {_model.Track}\n" +
$"Language = {_model.Language}"
]
]
];
private 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).OnValidSubmit(m => Console.WriteLine(m.Username))[
Input.Bind(() => _model.Username),
Button.Type("submit")["Sign up"]
]
Submit runs the full validator pipeline (ValidateAsync), marks every registered field touched,
then routes:
- valid →
OnValidSubmit(or, if unset,OnSubmit/OnSubmitAsyncwith the rawFormData), - invalid →
OnInvalidSubmit.
OnValidSubmit / OnInvalidSubmit accept Action<TModel> or Func<TModel, Task> — the generic
overload narrows the delegate so you pass a bare lambda with no cast.
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).OnValidSubmitAsync(SaveAsync)[submitting => [
Input.Bind(() => _model.Username).Disabled(submitting),
Button.Type("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("md:col-span-7")[
Form.Model(_model).OnValidSubmitAsync(SaveAsync).Id("fss-form")[submitting => [
Label.Class($"{Tw.Label} font-semibold")["Username"],
Input.Bind(() => _model.Username)
.Class($"{Tw.Input} mb-2")
.Disabled(submitting)
.Placeholder("Pick a name…")
.Id("fss-input"),
Button.Type("submit")
.Class(Tw.BtnPrimary)
.Disabled(submitting)
.Id("fss-submit")[submitting ? "Saving…" : "Sign up"]
]]
],
Div.Class("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 SaveAsync(Model m)
{
await Task.Delay(800).ConfigureAwait(false);
_saved = m.Username;
}
private sealed class Model
{
public string Username { get; set; } = "";
}
}
Saved: (nothing yet)
The fixed-list forms are untouched and still bind exactly as before:
Form.Model(_model)[Input.Bind(() => _model.Username), Button.Type("submit")["Sign up"]]
Only a form offers the function form. It lives on FormBuild<T>, the chain Form.Model(…) hands
back, so Div[submitting => …] does not compile — there is no submit state behind a <div> to
report. See ISubmitAware.
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).OnValidSubmit(m => _submission = "Saved").Context(_ctx)[
Input.Bind(() => _model.Title),
Button.Type("button").OnClickAsync(() => _ctx.ValidateAsync().AsTask())["Validate now"],
Button.Type("submit").Disabled(_ctx.IsValidatingAny)["Save"]
]
Form requires either Model or Context — they are the two ways to open its chain.
Rendering messages
Two headless components read the context — both take a required Template: so you own the markup,
and both render nothing when there's nothing to show:
ValidationMessage.For(() => _model.Email).Template(errs => Div.Class("field-error")[errs[0]])
ValidationSummary.Template(entries => Ul[entries.Select(e => Li[Strong[e.Field], ": ", e.Message])])
ValidationMessage.For keys a single field; ValidationSummary 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 readonly RegistrationModel _model = new();
private string? _submission;
private static Component SummaryAlert(IReadOnlyList<ValidationEntry> entries) =>
Div.Class($"{Tw.AlertDanger} text-sm mb-0")[
Div.Class("font-semibold mb-1")[
UiIcon.Name(UiIconName.Warning).Class("me-1"),
$"Please fix {entries.Count} error{(entries.Count == 1 ? "" : "s")}:"
],
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).OnValidSubmit(m => _submission = $"Registered: {m.Name} <{m.Email}>").Class("flex flex-col gap-3")[
ValidationSummary.Template(SummaryAlert),
Div[
Label.For("v2-name").Class($"{Tw.Label} text-sm mb-1")["Name"],
Input.Bind(() => _model.Name).Id("v2-name").Class(Tw.Input)
],
Div[
Label.For("v2-email").Class($"{Tw.Label} text-sm mb-1")["Email"],
Input.Bind(() => _model.Email)
.Id("v2-email")
.Type(InputType.Email)
.Class(Tw.Input)
],
Div[
Label.For("v2-age").Class($"{Tw.Label} text-sm mb-1")["Age"],
Input.Bind(() => _model.Age).Id("v2-age").Class(Tw.Input)
],
Div[
Label.For("v2-plan").Class($"{Tw.Label} text-sm mb-1")["Plan"],
Select.Bind(() => _model.Plan).Id("v2-plan").Class(Tw.Select)[
Option.Value("")["— choose —"],
Option.Value("free")["Free"],
Option.Value("pro")["Pro"],
Option.Value("team")["Team"]
]
],
Div[
Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.CheckCircle).Class("me-1"), "Register"]
]
],
_submission is null
? null
: Div.Role("status").Class($"{Tw.AlertSuccess} text-sm mt-3 mb-0")[UiIcon.Name(UiIconName.CheckCircle).Class("me-2"), _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 derived readout rendered outside the control updates
live either way. 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;
// Input<T> 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 — Input.Bind(() => model.X): two-way binds and streams per keystroke through the EditContext.
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("md:col-span-6")[
Label.Class($"{Tw.Label} font-semibold")["Controlled (Value + OnChange)"],
Input
.Value(_controlled)
.OnChange(v => _controlled = v)
.Class($"{Tw.Input} mb-2")
.Placeholder("Type, then blur…")
.Id("fc-input-controlled"),
P.Class("text-sm text-ui-muted mb-0").Id("fc-input-controlled-out")[
"Echo: ", Strong[_controlled.Length == 0 ? "(empty)" : _controlled]
]
],
Div.Class("md:col-span-6")[
Label.Class($"{Tw.Label} font-semibold")["Bound (two-way)"],
Form.Model(_model)[
Input.Bind(() => _model.Text)
.Class($"{Tw.Input} mb-2")
.Placeholder("Type…")
.Id("fc-input-bound")
],
P.Class("text-sm text-ui-muted 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;
// Textarea<T> 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 — Textarea.Bind(() => model.X): two-way binds and streams per keystroke through the EditContext.
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("md:col-span-6")[
Label.Class($"{Tw.Label} font-semibold")["Controlled (Value + OnChange)"],
Textarea
.Value(_controlled)
.OnChange(v => _controlled = v)
.Class($"{Tw.Input} mb-2")
.Rows(3)
.Placeholder("Type, then blur…")
.Id("fc-textarea-controlled"),
P.Class("text-sm text-ui-muted mb-0").Id("fc-textarea-controlled-out")[
"Length: ", Strong[_controlled.Length.ToString()]
]
],
Div.Class("md:col-span-6")[
Label.Class($"{Tw.Label} font-semibold")["Bound (two-way)"],
Form.Model(_model)[
Textarea.Bind(() => _model.Bio)
.Class($"{Tw.Input} mb-2")
.Rows(3)
.Placeholder("Type…")
.Id("fc-textarea-bound")
],
P.Class("text-sm text-ui-muted mb-0").Id("fc-textarea-bound-out")[
"Length: ", Strong[_model.Bio.Length.ToString()]
]
]
];
private sealed class Model
{
public string Bio { get; set; } = "";
}
}
Length: 0
Length: 0
namespace Rask.Site.Features;
// Select<T> 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 — 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 string _controlled = "Rask";
private readonly Model _model = new();
protected override Component? Render() =>
Div.Class("grid grid-cols-12 gap-4")[
Div.Class("md:col-span-6")[
Label.Class($"{Tw.Label} font-semibold")["Controlled (Value + OnChange)"],
Select
.Value(_controlled)
.OnChange(v => _controlled = v)
.Class($"{Tw.Select} mb-2")
.Id("fc-select-controlled")[
Option.Value("Rask"), Option.Value("Blazor"), Option.Value("htmx")
],
P.Class("text-sm text-ui-muted mb-0").Id("fc-select-controlled-out")[
"Picked: ", Strong[_controlled]
]
],
Div.Class("md:col-span-6")[
Label.Class($"{Tw.Label} font-semibold")["Bound (two-way)"],
Form.Model(_model)[
Select.Bind(() => _model.Framework).Class($"{Tw.Select} mb-2").Id("fc-select-bound")[
Option.Value("Rask"), Option.Value("Blazor"), Option.Value("htmx")
]
],
P.Class("text-sm text-ui-muted 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
Floating labels. The reusable Floating* wrappers (input/select/textarea) render a
floating-label field with the label derived from the bound property, and surface validation via
.field-error:
using System.ComponentModel.DataAnnotations;
using Rask.Site;
namespace Rask.Site.Features;
public sealed partial class FloatingLabelsDemo : Component
{
private readonly AccountModel _model = new();
private string? _submission;
protected override Component? Render() =>
[
Form.Model(_model).OnValidSubmit(m => _submission = $"Created account for {m.FullName} <{m.Email}>").Class("flex flex-col gap-2")[
// One line per field — the Floating* components wrap Input/Select/Textarea + Label +
// ValidationMessage in Bootstrap's .form-floating markup. The label is read from each
// property's [Display(Name)], the input type is inferred from the property's CLR type,
// and validation flows from the [Required]/[Range]/etc. attributes through
// the built-in DataAnnotations pass. Every property is nullable — Rask clears to null.
FloatingInput.Bind(() => _model.FullName),
FloatingInput.Bind(() => _model.Email),
FloatingInput.Bind(() => _model.Age),
FloatingSelect.Bind(() => _model.Plan)[
Option.Value("")["— choose —"],
Option.Value("free")["Free"],
Option.Value("pro")["Pro"],
Option.Value("team")["Team"]
],
FloatingTextarea.Bind(() => _model.Bio),
Div.Class("mt-1")[
Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.UserPlus).Class("me-1"), "Create account"]
]
],
_submission is null
? null
: Div.Role("status").Class($"{Tw.AlertSuccess} text-sm mt-3 mb-0")[UiIcon.Name(UiIconName.CheckCircle).Class("me-2"), _submission]
];
}
public sealed class AccountModel
{
[Display(Name = "Full name")]
[Required(ErrorMessage = "Full name is required.")]
[StringLength(60, MinimumLength = 2, ErrorMessage = "Full name must be 2–60 characters.")]
public string? FullName { get; set; }
[Display(Name = "Email address")]
[Required(ErrorMessage = "Email is required.")]
[EmailAddress(ErrorMessage = "Enter a valid email address.")]
public string? Email { get; set; }
[Display(Name = "Age")]
[Range(18, 120, ErrorMessage = "Age must be between 18 and 120.")]
public int? Age { get; set; }
[Display(Name = "Plan")]
[Required(ErrorMessage = "Pick a plan.")]
public string? Plan { get; set; }
[Display(Name = "Short bio")]
[StringLength(200, ErrorMessage = "Bio must be 200 characters or fewer.")]
public string? Bio { get; set; }
}
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", an aria-describedby that
points at the error message's id (and the help-text id when HelpText: is set), and the
.invalid-feedback as a role="alert" live region so screen readers announce the error the moment it
appears, associated with the field rather than detached from it. Valid fields with HelpText: still get
aria-describedby to the help text.
A combobox control — UiSelect<T> with Native: false — carries role="combobox",
which is not a labelable element, so its name is given directly (aria-label, or aria-labelledby
pointing at a visible label) rather than through a <label for> that would bind to nothing. 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.
If you build your own control from the core Input/ValidationMessage 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.