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

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


1. Two-way binding

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


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

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

BindingManualDemo.cs

namespace Rask.Site.Features;

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

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

    protected override Component? Render() =>
    [
        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}\""]
        ]
    ];
}
Live result

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.

BindingTypedDemo.cs

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; } = "";
    }
}
Live result

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 with Name:.
  • Input type ← the property's CLR type (BindingHelpers.DefaultInputType): bool → checkbox, every numeric primitive → number, DateOnly → date, DateTime/DateTimeOffset → datetime-local, TimeOnly/TimeSpan → time, everything else → text. Override with Type: — an InputType enum value. The full set is Text, Search, Tel, Url, Email, Password, Number, Checkbox, Radio, File, Range, Color, Date, DatetimeLocal (renders datetime-local), Time, Week, Month, Hidden, Button, Submit, Reset, Image. The string-only family (Text/Search/Tel/Url/Email/ Password) only makes sense on an Input<string>; setting one on a non-string bound input is RASK025.
  • Update timingstring fields update on every keystroke (OnInput); every other type updates on OnChange (blur). Textarea(() => …) always streams on OnInput.

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

File inputs

InputType.File turns an <input> into a file picker. Instead of binding a value, hand it an OnFiles (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.

BindingMultiDemo.cs

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;
    }
}
Live result
Subscribe = false
Age       = 30
StartDate = 2026-01-01
Favorite  = Blue

Textareas always stream — Textarea.Bound wires OnInputAsync for every keystroke so the echo updates without blur or submit.

BindingTextareaDemo.cs

namespace Rask.Site.Features;

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

    protected override Component? Render() =>
    [
        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; } = "";
    }
}
Live result
Notes  = ""
Length = 0

Empty value handling

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

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

A value that fails to parse ("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.

BindingNullableDemo.cs

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; }
    }
}
Live result
OptionalAge = null
StartDate   = null
Favorite    = null
Nickname    = null
BindingClearDefaultDemo.cs

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;
    }
}
Live result
Age         = 30
OptionalAge = 7

Binding 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])]
BindingAfterBindDemo.cs

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";
    }
}
Live result
Country = US
City    = New York

AfterBindAsync 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():

BindingAfterBindAsyncDemo.cs

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; } = "";
    }
}
Live result
Track    = 
Language = 

What a bind costs, and the one case worth changing

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

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

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

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

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


using System.Linq.Expressions;

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

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

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

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

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

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

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

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


2. Form.Model(…) and the EditContext

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


Form.Model(_model).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 / OnSubmitAsync with the raw FormData),
  • 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.

FormSubmitStateDemo.cs

namespace Rask.Site.Features;

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

    protected override Component? Render() =>
        Div.Class("grid grid-cols-12 gap-4")[
            Div.Class("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; } = "";
    }
}
Live result

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.

ValidationSummaryDemo.cs

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]
    ];
}
Live result

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>.

FormControlsInputDemo.cs

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; } = "";
    }
}
Live result

Echo: (empty)

Echo: (empty)

FormControlsTextareaDemo.cs

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; } = "";
    }
}
Live result

Length: 0

Length: 0

FormControlsSelectDemo.cs

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";
    }
}
Live result

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:

FloatingLabelsDemo.cs

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; }
}
Live result

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-disabledpresent 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.