Forms — nested models & control groups
Nested/complex models, radio & checkbox groups, and building your own form controls.
‹ Back to Forms & validation
Nested / complex models
Bind and validation extend transparently through sub-objects and collections. The form's built-in
validation covers the whole reachable graph — no per-level opt-in, and nothing declared. FieldIdentifier is reference-based (keyed off the
owner sub-instance, not a dotted path from the root), so removing or replacing a row drops its error
state with it.
public sealed class CheckoutModel
{
[Required] public string Name { get; set; } = "";
public AddressModel Address { get; set; } = new();
public List<LineItem> Items { get; set; } = new();
}
public sealed class AddressModel
{
[Required] public string Street { get; set; } = "";
[Required, RegularExpression("^[A-Z]{2}$")] public string Country { get; set; } = "";
}
Sub-object binding uses the same Bind: () => … shape:
Input.Bind(() => _model.Address.Street),
ValidationMessage.For(() => _model.Address.Street).Template(errs => Div.Class("err")[errs[0]]),
Collection binding — foreach + per-item capture (the canonical pattern). Each iteration closes
over a distinct item, so each row's lambda targets its own instance:
foreach (var item in _model.Items)
{
rows.Add(Tr[
Td[Input.Bind(() => item.Description)],
Td[Input.Bind(() => item.Quantity)],
Td[Button.Type("button").OnClick(() => _model.Items.Remove(item))["×"]]
]);
}
Collection binding — indexer style when you need the row number, or for records that get
replaced rather than mutated (() => model.Items[i].Name re-resolves the slot every render). Watch
the classic for closure trap — copy the index into a per-iteration local:
for (var idx = 0; idx < _model.Items.Count; idx++)
{
var i = idx; // per-iteration capture, NOT idx
rows.Add(Tr[
Td[$"#{i + 1}"],
Td[Input.Bind(() => _model.Items[i].Description)],
Td[Input.Bind(() => _model.Items[i].Quantity)]
]);
}
foreach has no closure trap. Records with init-only properties can't be auto-bound through the
setter — declare them { get; set; }, or use the indexer pattern with a manual handler that replaces
the slot (_model.Items[i] = _model.Items[i] with { Field = newValue }).
FluentValidation nesting uses SetValidator(...) and RuleForEach(...).SetValidator(...); Rask
routes the dotted error.PropertyName (Address.Street, Lines[0].Quantity) back to the runtime
sub-instance so ValidationMessage(For: () => _model.Address.Street, …) reads the right slot.
Trimming. Validating a nested graph reflects over every reachable model type. Whatever preserves the root model's public properties (
[DynamicallyAccessedMembers], a routed page, or a trimmer descriptor) must extend to every nested type.
The four patterns, live — sub-object binding, foreach and indexer collection binding, and
FluentValidation nesting:
using System.ComponentModel.DataAnnotations;
namespace Rask.Site.Features;
// Sub-object binding — sub-class instance owns its own validation state under a single
// the form's built-in validation, with nothing declared.
public sealed partial class NestedSubObjectDemo : Component
{
private readonly CheckoutModel _model = new();
private string? _submission;
private static Component FieldError(IReadOnlyList<string> msgs) =>
[.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];
protected override Component? Render() =>
[
Form.Model(_model).OnValidSubmit(m => _submission =
$"Checked out as {m.Name} to {m.Address.Street}, {m.Address.City} ({m.Address.Country}).").Class("flex flex-col gap-3")[
Div[
Label.For("nf-name").Class($"{Tw.Label} text-sm mb-1")["Name"],
Input.Bind(() => _model.Name).Id("nf-name").Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => _model.Name)
],
Div[
Label.For("nf-email").Class($"{Tw.Label} text-sm mb-1")["Email"],
Input.Bind(() => _model.Email)
.Id("nf-email")
.Type(InputType.Email)
.Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => _model.Email)
],
Fieldset.Class("border rounded p-3 mt-2")[
Legend.Class("text-base font-semibold")["Shipping address"],
Div.Class("flex flex-col gap-3")[
Div[
Label.For("nf-street").Class($"{Tw.Label} text-sm mb-1")["Street"],
Input.Bind(() => _model.Address.Street)
.Id("nf-street")
.Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => _model.Address.Street)
],
Div[
Label.For("nf-city").Class($"{Tw.Label} text-sm mb-1")["City"],
Input.Bind(() => _model.Address.City)
.Id("nf-city")
.Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => _model.Address.City)
],
Div[
Label.For("nf-country").Class($"{Tw.Label} text-sm mb-1")["Country (ISO)"],
Input.Bind(() => _model.Address.Country)
.Id("nf-country")
.Class(Tw.Input)
.MaxLength(2),
ValidationMessage.Template(FieldError).For(() => _model.Address.Country)
]
]
],
Div[
Button.Class(Tw.BtnPrimary).Type("submit").Id("nf-submit")[
UiIcon.Name(UiIconName.CheckCircle).Class("me-1"), "Place order"]
]
],
_submission is null
? null
: Div.Class($"{Tw.AlertSuccess} text-sm mt-3 mb-0").Id("nf-result")[
UiIcon.Name(UiIconName.CheckCircle).Class("me-2"), _submission]
];
}
public sealed class CheckoutModel
{
[Required(ErrorMessage = "Name is required.")]
[StringLength(60)]
public string Name { get; set; } = "";
[Required(ErrorMessage = "Email is required.")]
[EmailAddress(ErrorMessage = "Looks like an invalid email.")]
public string Email { get; set; } = "";
public AddressModel Address { get; set; } = new();
}
public sealed class AddressModel
{
[Required(ErrorMessage = "Street is required.")]
public string Street { get; set; } = "";
[Required(ErrorMessage = "City is required.")]
public string City { get; set; } = "";
[Required(ErrorMessage = "Country is required.")]
[RegularExpression("^[A-Z]{2}$", ErrorMessage = "Use the ISO 2-letter code.")]
public string Country { get; set; } = "";
}
using System.ComponentModel.DataAnnotations;
namespace Rask.Site.Features;
// Collection binding via foreach-capture — the canonical pattern.
public sealed partial class NestedListForeachDemo : Component
{
private readonly CartModel _model = new();
private int _seq = 2;
private string? _submission;
public NestedListForeachDemo() =>
_model.Items.Add(new LineItem { Description = "Coffee beans (250g)", Quantity = 2 });
private static Component FieldError(IReadOnlyList<string> msgs) =>
[.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];
protected override Component? Render()
{
var rows = new List<Component>();
foreach (var item in _model.Items)
{
var captured = item; // foreach already captures per-iteration but make it loud.
rows.Add(Tr.Key(captured.Id)[
Td[
Input.Bind(() => captured.Description).Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => captured.Description)
],
Td.Style("width: 6rem;")[
Input.Bind(() => captured.Quantity).Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => captured.Quantity)
],
Td.Style("width: 3rem;")[
Button.Type("button").Class(Tw.BtnOutlineDanger)
.OnClick(() => _model.Items.Remove(captured))[UiIcon.Name(UiIconName.Close)]
]
]);
}
return
[
Form.Model(_model).OnValidSubmit(m => _submission = $"Submitted {m.Items.Count} line item(s).").Class("flex flex-col gap-3")[
Table.Class($"{Tw.Table} text-sm align-middle mb-0")[
Thead[Tr[Th["Description"], Th["Quantity"], Th]],
Tbody[rows]
],
Div.Class("flex gap-2 flex-wrap items-center")[
Button.Type("button").Class(Tw.BtnOutlineSecondary)
.Id("nf-list-add")
.OnClick(() =>
_model.Items.Add(new LineItem { Description = $"New item #{_seq++}", Quantity = 1 }))[
UiIcon.Name(UiIconName.Plus).Class("me-1"), "Add row"],
Button.Class(Tw.BtnPrimary).Type("submit").Id("nf-list-submit")[
UiIcon.Name(UiIconName.CheckCircle).Class("me-1"), "Submit"]
]
],
_submission is null
? null
: Div.Class($"{Tw.AlertSuccess} text-sm mt-3 mb-0").Id("nf-list-result")[_submission]
];
}
}
public sealed class CartModel
{
public List<LineItem> Items { get; set; } = new();
}
public sealed class LineItem
{
// Stable per-instance key for keyed row diffing (not bound to any input, no validation attrs).
public Guid Id { get; } = Guid.NewGuid();
[Required(ErrorMessage = "Description is required.")]
[StringLength(80)]
public string Description { get; set; } = "";
[Range(1, int.MaxValue, ErrorMessage = "Quantity must be at least 1.")]
public int Quantity { get; set; } = 1;
}
using System.ComponentModel.DataAnnotations;
namespace Rask.Site.Features;
// Collection binding via indexer — the for-loop variant. Useful when the row index matters
// (row numbers, reorder controls) or when items are records that get replaced rather than
// mutated. The `var i = idx;` per-iteration capture dodges the classic C# closure trap.
public sealed partial class NestedListIndexerDemo : Component
{
private readonly InvoiceModel _model = new();
private int _seq = 2;
private string? _submission;
public NestedListIndexerDemo() => _model.Skus.Add(new SkuRow { Code = "WIDGET-1", Price = 9.99m });
private static Component FieldError(IReadOnlyList<string> msgs) =>
[.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];
protected override Component? Render()
{
var rows = new List<Component>();
for (var idx = 0; idx < _model.Skus.Count; idx++)
{
var i = idx; // Per-iteration capture — without this every lambda closes over Skus.Count.
rows.Add(Tr.Key(_model.Skus[i].Id)[
Td.Class("text-ui-muted text-sm")[$"#{i + 1}"],
Td[
Input.Bind(() => _model.Skus[i].Code).Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => _model.Skus[i].Code)
],
Td.Style("width: 7rem;")[
Input.Bind(() => _model.Skus[i].Price).Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => _model.Skus[i].Price)
],
Td.Style("width: 5rem;")[
Button.Class($"{Tw.BtnOutlineSecondary} me-1").Type("button")
.Disabled(i == 0)
.OnClick(() => (_model.Skus[i - 1], _model.Skus[i]) = (_model.Skus[i], _model.Skus[i - 1]))[
UiIcon.Name(UiIconName.ArrowUp)],
Button.Type("button").Class(Tw.BtnOutlineDanger)
.OnClick(() => _model.Skus.RemoveAt(i))[UiIcon.Name(UiIconName.Close)]
]
]);
}
return
[
Form.Model(_model).OnValidSubmit(m => _submission =
$"Invoice with {m.Skus.Count} sku line(s) at total {m.Skus.Sum(s => s.Price):F2}").Class("flex flex-col gap-3")[
Table.Class($"{Tw.Table} text-sm align-middle mb-0")[
Thead[Tr[Th.Style("width: 3rem;")["#"], Th["SKU"], Th["Price"], Th]],
Tbody[rows]
],
Div.Class("flex gap-2 flex-wrap items-center")[
Button.Type("button").Class(Tw.BtnOutlineSecondary)
.Id("nf-idx-add")
.OnClick(() => _model.Skus.Add(new SkuRow { Code = $"WIDGET-{_seq++}", Price = 1.00m }))[
UiIcon.Name(UiIconName.Plus).Class("me-1"), "Add row"],
Button.Class(Tw.BtnPrimary).Type("submit").Id("nf-idx-submit")[
UiIcon.Name(UiIconName.CheckCircle).Class("me-1"), "Submit"]
]
],
_submission is null
? null
: Div.Class($"{Tw.AlertSuccess} text-sm mt-3 mb-0").Id("nf-idx-result")[_submission]
];
}
}
public sealed class InvoiceModel
{
public List<SkuRow> Skus { get; set; } = new();
}
public sealed class SkuRow
{
// Stable per-instance key for keyed row diffing — survives the up/down reorder.
public Guid Id { get; } = Guid.NewGuid();
[Required(ErrorMessage = "SKU is required.")]
[RegularExpression("^[A-Z0-9-]{3,12}$", ErrorMessage = "Use uppercase letters, digits, and dashes (3-12 chars).")]
public string Code { get; set; } = "";
[Range(0.01, 99999.99, ErrorMessage = "Price must be greater than 0.")]
public decimal Price { get; set; } = 0.01m;
}
using FluentValidation;
namespace Rask.Site.Features;
// FluentValidation with SetValidator + RuleForEach — one root validator covers the whole
// graph; Rask routes dotted property paths back to the runtime sub-instance.
public sealed partial class NestedFluentValidationDemo : Component
{
private readonly NestedOrderModel _model = new();
private int _seq = 2;
private string? _submission;
public NestedFluentValidationDemo() => _model.Lines.Add(new NestedOrderLine { Sku = "BOX-1", Quantity = 3 });
private static Component FieldError(IReadOnlyList<string> msgs) =>
[.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];
protected override Component? Render()
{
var rows = new List<Component>();
foreach (var line in _model.Lines)
{
var captured = line;
rows.Add(Tr.Key(captured.Id)[
Td[
Input.Bind(() => captured.Sku).Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => captured.Sku)
],
Td.Style("width: 6rem;")[
Input.Bind(() => captured.Quantity).Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => captured.Quantity)
],
Td.Style("width: 3rem;")[
Button.Type("button").Class(Tw.BtnOutlineDanger)
.OnClick(() => _model.Lines.Remove(captured))[UiIcon.Name(UiIconName.Close)]
]
]);
}
return
[
Form.Model(_model).OnValidSubmit(m => _submission = $"Order routed: {m.CustomerName} → {m.Address.Street}, {m.Lines.Count} line(s)").Class("flex flex-col gap-3")[
Div[
Label.For("nf-fv-name").Class($"{Tw.Label} text-sm mb-1")["Customer"],
Input.Bind(() => _model.CustomerName).Id("nf-fv-name").Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => _model.CustomerName)
],
Fieldset.Class("border rounded p-3")[
Legend.Class("text-base font-semibold")["Address"],
Div.Class("flex flex-col gap-2")[
Div[
Input.Bind(() => _model.Address.Street).Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => _model.Address.Street)
],
Div[
Input.Bind(() => _model.Address.City).Class(Tw.Input),
ValidationMessage.Template(FieldError).For(() => _model.Address.City)
]
]
],
Table.Class($"{Tw.Table} text-sm align-middle mb-0 mt-2")[
Thead[Tr[Th["SKU"], Th["Qty"], Th]],
Tbody[rows]
],
Div.Class("flex gap-2 flex-wrap items-center")[
Button.Type("button").Class(Tw.BtnOutlineSecondary)
.Id("nf-fv-add")
.OnClick(() => _model.Lines.Add(new NestedOrderLine { Sku = $"BOX-{_seq++}", Quantity = 1 }))[
UiIcon.Name(UiIconName.Plus).Class("me-1"), "Add line"],
Button.Class(Tw.BtnPrimary).Type("submit").Id("nf-fv-submit")[
UiIcon.Name(UiIconName.CheckCircle).Class("me-1"), "Place"]
]
],
_submission is null
? null
: Div.Class($"{Tw.AlertSuccess} text-sm mt-3 mb-0").Id("nf-fv-result")[_submission]
];
}
}
public sealed class NestedOrderModel
{
public string CustomerName { get; set; } = "";
public NestedOrderAddress Address { get; set; } = new();
public List<NestedOrderLine> Lines { get; set; } = new();
}
public sealed class NestedOrderAddress
{
public string Street { get; set; } = "";
public string City { get; set; } = "";
}
public sealed class NestedOrderLine
{
// Stable per-instance key for keyed row diffing.
public Guid Id { get; } = Guid.NewGuid();
public string Sku { get; set; } = "";
public int Quantity { get; set; } = 1;
}
public sealed class NestedOrderValidator : AbstractValidator<NestedOrderModel>
{
public NestedOrderValidator()
{
RuleFor(x => x.CustomerName).NotEmpty().WithMessage("Customer name is required.");
RuleFor(x => x.Address).SetValidator(new NestedOrderAddressValidator());
RuleForEach(x => x.Lines).SetValidator(new NestedOrderLineValidator());
}
}
public sealed class NestedOrderAddressValidator : AbstractValidator<NestedOrderAddress>
{
public NestedOrderAddressValidator()
{
RuleFor(x => x.Street).NotEmpty().WithMessage("Street is required.");
RuleFor(x => x.City).NotEmpty().WithMessage("City is required.");
}
}
public sealed class NestedOrderLineValidator : AbstractValidator<NestedOrderLine>
{
public NestedOrderLineValidator()
{
RuleFor(x => x.Sku).NotEmpty().WithMessage("SKU is required.");
RuleFor(x => x.Quantity).GreaterThan(0).WithMessage("Quantity must be positive.");
}
}
A nested graph with async validators and live totals rolling up from the rows:
using System.Globalization;
using System.Text.RegularExpressions;
namespace Rask.Site.Features;
public sealed partial class NestedAsyncWithLiveTotalsDemo : Component
{
// Layers two things on top of the basic nested-binding showcase:
// * Async inline Validate: on a nested field (Address.PostalCode) with ValidatingIndicator —
// proves the latest-wins cancellation + pending-indicator path works for sub-objects, not
// just root fields.
// * Live derived UI: the order totals are computed inside Render() from the current model
// state. Every event handler re-renders the owning component, so the figures update on
// each keystroke (string discount code, OnInput) and on each blur (int/decimal qty/price,
// OnChange). No StateHasChanged calls needed — the dispatcher handles it.
private static readonly HashSet<string> UndeliverableZips =
new(StringComparer.Ordinal) { "00000", "99999" };
private static readonly Dictionary<string, decimal> PromoCodes =
new(StringComparer.OrdinalIgnoreCase) { ["SAVE10"] = 0.10m, ["SAVE25"] = 0.25m };
private readonly StorefrontModel _model = new()
{
CustomerName = "",
Address = new StorefrontAddress { PostalCode = "" },
Items =
{
new StorefrontLineItem { Name = "Widget", Quantity = 1, UnitPrice = 9.99m },
new StorefrontLineItem { Name = "Gadget", Quantity = 2, UnitPrice = 14.99m }
},
DiscountCode = ""
};
private string? _submission;
private static Component FieldError(IReadOnlyList<string> msgs) =>
[.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])];
private static Component Checking() =>
Span.Class("validating-indicator text-ui-muted text-sm mt-1")[
UiIcon.Name(UiIconName.Retry).Class("me-1"), "Checking delivery zone…"
];
private static async ValueTask<IEnumerable<string>> ValidatePostalAsync(
string code, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(code))
{
return new[] { "Postal code is required." };
}
if (!Regex.IsMatch(code, @"^\d{5}$"))
{
return new[] { "Postal code must be 5 digits." };
}
// Fake reverse-geocode lookup — the 300ms delay is what drives latest-wins cancellation
// when the user keeps typing past a partial match. ConfigureAwait(false) is required:
// the inline async-validator path runs inside HandlerSyncContext, and a captured
// continuation here would race the outer InvokeWithRenderingAsync mid-await render
// (concurrent WebSocket.SendAsync calls deadlock on the same socket).
await Task.Delay(300, ct).ConfigureAwait(false);
return UndeliverableZips.Contains(code)
? new[] { "We don't ship to this area." }
: Array.Empty<string>();
}
protected override Component? Render()
{
// Live derived state — recomputed on every render. The dispatcher re-renders this
// component after each event handler completes, so the figures stay in sync with the
// model without any explicit subscription.
var subtotal = _model.Items.Sum(i => i.Quantity * i.UnitPrice);
var discountPct = PromoCodes.TryGetValue(_model.DiscountCode ?? "", out var p) ? p : 0m;
var discount = Math.Round(subtotal * discountPct, 2);
var afterDiscount = subtotal - discount;
var tax = Math.Round(afterDiscount * 0.08m, 2);
var total = afterDiscount + tax;
return
[
Form.Model(_model)
.OnValidSubmit(m =>
_submission = $"Charged ${total.ToString("F2", CultureInfo.InvariantCulture)} to {m.CustomerName}")
.Class("flex flex-col gap-3")[
Div[
Label.For("v-nlive-name").Class($"{Tw.Label} text-sm mb-1")["Customer name"],
Input.Bind(() => _model.CustomerName)
.Id("v-nlive-name")
.Class(Tw.Input)
.Validate(v =>
string.IsNullOrWhiteSpace(v)
? new[] { "Name is required." }
: Array.Empty<string>()),
ValidationMessage.Template(FieldError).For(() => _model.CustomerName)
],
Div[
Label.For("v-nlive-postal").Class($"{Tw.Label} text-sm mb-1")[
"Postal code ", Span.Class("text-ui-muted")["(try 12345, 99999, or any 5-digit code)"]
],
Input.Bind(() => _model.Address.PostalCode)
.Id("v-nlive-postal")
.Class(Tw.Input)
.ValidateAsync(ValidatePostalAsync),
ValidatingIndicator.Template(Checking).For(() => _model.Address.PostalCode),
ValidationMessage.Template(FieldError).For(() => _model.Address.PostalCode)
],
Div.Class("border rounded p-3")[
Div.Class("font-semibold text-sm mb-2")["Items"],
Div.Class("grid grid-cols-12 gap-4 mb-2 items-center")[
Div.Class("col-span-6")[
Input.Bind(() => _model.Items[0].Name)
.Id("v-nlive-item0-name")
.Class(Tw.Input)
],
Div.Class("col-span-3")[
Input.Bind(() => _model.Items[0].Quantity)
.Id("v-nlive-item0-qty")
.Class(Tw.Input)
.Min("0")
],
Div.Class("col-span-3")[
Input.Bind(() => _model.Items[0].UnitPrice)
.Id("v-nlive-item0-price")
.Class(Tw.Input)
.Step("0.01")
]
],
Div.Class("grid grid-cols-12 gap-4 items-center")[
Div.Class("col-span-6")[
Input.Bind(() => _model.Items[1].Name)
.Id("v-nlive-item1-name")
.Class(Tw.Input)
],
Div.Class("col-span-3")[
Input.Bind(() => _model.Items[1].Quantity)
.Id("v-nlive-item1-qty")
.Class(Tw.Input)
.Min("0")
],
Div.Class("col-span-3")[
Input.Bind(() => _model.Items[1].UnitPrice)
.Id("v-nlive-item1-price")
.Class(Tw.Input)
.Step("0.01")
]
]
],
Div[
Label.For("v-nlive-promo").Class($"{Tw.Label} text-sm mb-1")[
"Promo code ", Span.Class("text-ui-muted")["(try SAVE10 or SAVE25)"]
],
Input.Bind(() => _model.DiscountCode).Id("v-nlive-promo").Class(Tw.Input)
],
Div.Id("v-nlive-totals").Class("bg-ui-well rounded p-3 text-sm")[
Div.Class("flex justify-between flex-wrap items-center")[
Span["Subtotal"],
Span.Id("v-nlive-subtotal")[$"${subtotal.ToString("F2", CultureInfo.InvariantCulture)}"]
],
Div.Class("flex justify-between flex-wrap items-center")[
Span[discountPct > 0m
? $"Discount ({(int)(discountPct * 100)}%)"
: "Discount"],
Span.Id("v-nlive-discount")[$"-${discount.ToString("F2", CultureInfo.InvariantCulture)}"]
],
Div.Class("flex justify-between flex-wrap items-center")[
Span["Tax (8%)"],
Span.Id("v-nlive-tax")[$"${tax.ToString("F2", CultureInfo.InvariantCulture)}"]
],
Hr.Class("my-2"),
Div.Class("flex justify-between flex-wrap items-center font-bold")[
Span["Total"],
Span.Id("v-nlive-total")[$"${total.ToString("F2", CultureInfo.InvariantCulture)}"]
]
],
Div[
Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.CreditCard).Class("me-1"), "Pay"]
]
],
_submission is null
? null
: Div.Id("v-nlive-submission").Class($"{Tw.AlertSuccess} text-sm mt-3 mb-0")[
UiIcon.Name(UiIconName.CheckCircle).Class("me-2"), _submission
]
];
}
}
public sealed class StorefrontModel
{
public string CustomerName { get; set; } = "";
public StorefrontAddress Address { get; set; } = new();
public List<StorefrontLineItem> Items { get; set; } = new();
public string DiscountCode { get; set; } = "";
}
public sealed class StorefrontAddress
{
public string PostalCode { get; set; } = "";
}
public sealed class StorefrontLineItem
{
public string Name { get; set; } = "";
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
Radio & checkbox groups (example components)
RadioGroup<TValue> binds one value from a set of options; CheckboxGroup<TItem> binds an
ICollection<TItem>. Build a typed version of your own as
a radio group, a checkbox group or a multi-select of your own. The versions below are
a copyable worked example of the binding API of §9 — IFormControl<T>
is the framework primitive; the control is yours to build or take from the package. They're structured exactly like
MultiSelect<TItem>, with bound and controlled modes (so the generator emits both chains):
// Bound — two-way binds the model, with an optional per-field Validate rule.
Form.Model(_prefs)[
RadioGroup(() => _prefs.Plan, // single value
new[] { Plan.Free, Plan.Pro, Plan.Team },
ItemClass: "form-check-inline"),
CheckboxGroup<string>(() => _prefs.Interests, // a collection
new[] { "Web", "Mobile", "AI", "Games" },
Validate: tags => tags.Count >= 1 ? [] : ["Pick at least one."])
]
// Controlled — the parent owns the value; OnChange (auto-wrapped) re-renders it.
RadioGroup(plans, Value: _plan, OnChange: v => _plan = v)
CheckboxGroup<string>(interests, Value: _interests, OnChange: next => _interests = next)
- Bound mode takes the
Bindexpression first;Validatefans into none/sync/async overloads likeInput(§9).RadioGrouprenders the option equal to the current valuecheckedand sets the bound property on select;CheckboxGroupmutates the bound collection (membership byEqualityComparer<TItem>.Default) — you usually need the explicitCheckboxGroup<string>when the collection is a concreteList<T>. Each change callsNotifyFieldChanged+NotifyFieldTouched+ValidateFieldAsync, so DataAnnotations / FluentValidation rules apply. - Each item renders an
<input>and a<label>tied together byid/for, so the pair is one target for a pointer and one stop for a screen reader.ItemClassadds extra classes;OptionLabelcustomizes the label. - On a radio or checkbox group of your own, pass a label to give the group an accessible
name: the options are then wrapped in a
<fieldset>titled by a<legend>, which is the correct grouping semantics for a set of related radios/checkboxes. Without aLabelyou get the bare per-item fragment (so you can supply your own<fieldset>/heading). An unnamed control derives a page-unique fallbackname, so two on one page are never merged into a single browser radio group. - They are Components (their own re-render boundary), so a toggle re-renders the control itself; for
host-side derived UI (a live summary) use controlled mode — the auto-wrapped
OnChangere-renders the host. (In bound mode, feedback lives inside the control via the embeddedValidationMessage.) - Reading validation state in a custom control just works. If you bake feedback straight into your
own
Render()— readingEditContext.GetValidationMessages(field)/GetValidationEntries()/ShouldShowValidatingIndicator(field)— the framework detects the read and opts that control out of its render cache automatically, so a message produced later in the submit pipeline always repaints. NoStateHasChanged(), noBypassRenderCacheoverride (the same auto-opt-outContext.Getconsumers get).
RadioGroup (single value) and CheckboxGroup (a collection), live:
A drawn single-select. UiSelect<T> binds one T and renders the platform's
<select> by default; Native: false draws the list itself instead — a [popover] role="listbox"
under a role="combobox" box, with the arrow keys, Home/End, Enter and a roving
aria-activedescendant cursor that skips unavailable options. Reach for it when the list has to carry
more than the platform will show (groups, options that are visibly unavailable) or has to escape an
overflow: hidden ancestor. The drawn list needs the runtime; the native one does not.
A plain <select multiple> bound to a collection. Select(() => …).Multiple(true) binds the
whole selection when T is a string collection — string[], List<string>, HashSet<string>, or the
IReadOnlyList<string> / IList<string> / ICollection<string> / IEnumerable<string> interfaces:
Select.Bind(() => model.Tags).Multiple(true)[
Option("news"), Option("sport"), Option("weather")
]
Every picked option is marked on render, and each change replaces the collection rather than editing its membership — the browser reports the absolute selection every time, so a replace re-syncs the model even if an intermediate render was coalesced.
Two limits worth knowing:
- The element type is
string. The reflective version that would accept any parsable element needsMakeGenericTypeandArray.CreateInstance, both of which are AOT-hostile — andsite/Rask.Sitehas to publish with zero trim warnings. Bindstring[]and convert. Multiple: trueover a scalar property keeps the single-value binding. That is a model which can only hold one answer; widening it silently would be the more surprising behaviour.
Surviving a redeploy
If the server is replaced while someone is filling a form in, the page may have to reload — and the fields they had edited are put back. It is a three-way merge, so a field is only re-applied when the replacement server rendered the same value the old one had: if its state changed in the meantime, the server wins and the stale edit is dropped. Whatever is restored is pushed back over the socket, so the model matches what the page shows. Shutdown and redeploy has the full rules.
Two things to know when building a form:
- A field needs an
idor aname. A boundInputgets anamefrom the bound property for free, so this is usually nothing to think about — but a key that matches more than one control on the page is skipped rather than guessed at, and a control with neither is never restored. data-rask-no-restoreopts out a field, or every field under it:
Div.Data("rask-no-restore")[
Input.Bind(() => _model.CouponCode).Class("w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-violet-500 focus:outline-none dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100") // never carried across a reload
]
Passwords, file, hidden and one-time-code inputs, and anything with a cc-* / current-password /
new-password autocomplete, are excluded unconditionally — they never reach sessionStorage at all.
<select> isn't restored yet.
Building your own form controls
The binding system is public: a custom control implementing IFormControl<T> gets generator-synthesized
bound + controlled chains, per-field validation, and the same ergonomics as the built-ins — see the
dedicated guide building-form-controls.md (with a complete worked example
and the IFormControl<T> helper reference). RadioGroup/CheckboxGroup (§8) and the showcase
MultiSelect<TItem> are built entirely on it.