Forms and Validation in Reactor
Reactor forms use a controlled-input pattern — every input has an
explicit (value, setter) pair driven by UseState. There is no two-way
binding. Validation is layered on top via UseValidationContext and
declarative .Validate() modifiers.
Controlled prop note: factory call sites keep the plain (value, setter) shape, but the underlying element records use Optional<T>.
Read element props with .Value / .GetValueOrDefault(...); use
Optional<T>.Unset only when the WinUI control should own the value. See
migration/050-optional-t.md.
Quick reference
| API |
Purpose |
TextBox(value, setValue) |
Controlled text input |
UseValidationContext() |
Track validation messages, touched/dirty state |
.Validate(...) |
Attach built-in validators to an input |
FormField(input, label: ...) |
Wraps input with label, error display, required marker |
new MaskEngine(...) |
Masked text input (phone, SSN, etc.) |
InputFormatter.Currency(...) |
Format-as-you-type |
1. Controlled inputs
Every input takes (value, setter). State lives in the component:
var (name, setName) = UseState("");
var (age, setAge) = UseState(0);
var (agreed, setAgreed) = UseState(false);
return VStack(12,
TextBox(name, setName, placeholderText: "Name"),
NumberBox(age, setAge),
CheckBox(agreed, setAgreed, label: "I agree"),
Button("Submit", onSubmit).IsEnabled(!(string.IsNullOrEmpty(name) || !agreed))
);
Available input types
| Factory |
Value type |
Common modifiers |
TextBox(value, setValue, placeholderText, header) |
string |
.Header(), .IsReadOnly(), .AcceptsReturn(), .TextWrapping(), .MaxLength(n), .NumericInput(), .EmailInput(), .Changed(handler) |
PasswordBox(password, setPassword, placeholderText) |
string |
.Header(text), .MaxLength(n), .PasswordChanged(handler) |
NumberBox(value, setValue, header) |
double |
.PlaceholderText(text), .Range(min, max), .SpinButtons(...) |
Slider(value, min, max, setValue) |
double |
.Header(), .StepFrequency() |
ToggleSwitch(isOn, setIsOn, header, onContent, offContent) |
bool |
.Header() |
CheckBox(isChecked, setIsChecked, label) |
bool |
— |
RadioButtons(items, selected, setSelected, header) |
int |
.Set(rb => ...) |
ComboBox(items, selected, setSelected) |
object |
.Header(), .IsEditable(), .PlaceholderText(text) |
DatePicker(date, setDate, header) |
DateTimeOffset |
.Set(dp => ...) |
TimePicker(time, setTime, header) |
TimeSpan |
.Set(tp => ...) |
AutoSuggestBox(text, setText, onQuerySubmitted) |
string |
.PlaceholderText(text), .Set(asb => ...) |
RichEditBox(doc, setDoc, header) |
string |
.PlaceholderText(text), .Set(reb => ...) |
CalendarDatePicker(date, setDate) |
DateTimeOffset? |
.PlaceholderText(text), .Set(cdp => ...) |
Named-input shapes (Spec 039 §17.3): .NumericInput() / .EmailInput()
preconfigure InputScope and IME hints so on-screen / soft keyboards open
in the right mode. Stack them with validators:
TextBox(email, setEmail, placeholderText: "you@example.com")
.EmailInput()
.MaxLength(254)
.Validate("email", email, Validate.Required(), Validate.Email())
For modifiers that aren't in the typed surface (e.g. PasswordRevealMode,
date min/max), use .Set(native => ...) to reach the underlying WinUI control.
The full catalog is in references/reactor.api.txt.
2. Simple validation (derived booleans)
For trivial forms, derive validation from state:
var (email, setEmail) = UseState("");
var isValid = email.Contains('@') && email.Length > 3;
return VStack(12,
TextBox(email, setEmail, placeholderText: "Email"),
Button("Submit", onSubmit).IsEnabled(isValid)
);
This is fine for 1–2 fields. For anything more, use UseValidationContext.
3. UseValidationContext
Tracks per-field validation messages, touched/dirty state, and overall
form validity:
var validation = this.UseValidationContext();
var (name, setName) = UseState("");
var (email, setEmail) = UseState("");
return VStack(12,
TextBox(name, setName, placeholderText: "Name")
.Validate("name", name,
Validate.Required("Name is required"),
Validate.MinLength(2, "Name too short")),
TextBox(email, setEmail, placeholderText: "Email")
.Validate("email", email,
Validate.Required("Email is required"),
Validate.Email("Invalid email")),
Button("Submit", () =>
{
validation.MarkAllTouched();
if (validation.IsValid())
Submit(name, email);
})
);
.Validate(fieldName, value, ...) resolves the surrounding ValidationContext
through React-style ambient context — you do not pass validation explicitly.
Passing the current value (the second arg) opts in to auto-validation as the
component re-renders; the validator-only overload .Validate(fieldName, ...)
is for cases where you trigger validation manually.
ValidationContext API
| Member |
Purpose |
.IsValid() |
true when no field has Error-severity messages |
.IsDirty() |
true when any registered field differs from initial value |
.IsDirty("field") |
Per-field dirty check |
.MarkAllTouched() |
Mark every registered field touched (typical on submit) |
.MarkTouched("field") |
Mark a single field touched |
.Reset("field") |
Reset one field to initial value, returns the initial |
.ResetAll() |
Reset all fields to initial values |
.ClearAll() |
Clear all messages (preserve touched/initial state) |
.GetMessages("field") |
Get error messages for a specific field |
.IsTouched("field") |
Whether the user has interacted with a field |
4. Built-in validators
The .Validate() modifier accepts an array of validators:
| Validator |
Purpose |
Validate.Required(msg) |
Non-empty |
Validate.MinLength(n, msg) |
Minimum string length |
Validate.MaxLength(n, msg) |
Maximum string length |
Validate.Email(msg) |
Email format |
Validate.Match(pattern, msg) |
Custom regex pattern |
Validate.Range(min, max, msg) |
Numeric range |
Validate.Must<T>(predicate, msg) |
Arbitrary predicate |
Validate.EqualTo<T>(value, msg) |
Fields must match (confirm password) |
Validate.Url(msg) |
URL format |
Validate.MustBeTrue(msg) |
Boolean must be true (checkboxes) |
5. FormField helper
FormField wraps an input with a label, required marker, description
text, and error display:
var validation = this.UseValidationContext();
var (name, setName) = UseState("");
return FormField(
TextBox(name, setName, placeholderText: "Enter your name")
.Validate("name", name, Validate.Required("Required")),
label: "Full Name",
required: true,
description: "As it appears on your ID",
showWhen: ShowWhen.WhenTouched // or Always, WhenDirty, AfterFirstSubmit, Never
);
ShowWhen controls when error messages appear:
WhenTouched — after the user has interacted with the field (recommended default)
Always — immediately, even before user interaction
WhenDirty — only after the value has changed
AfterFirstSubmit — only after the first submit attempt
6. Masked input
MaskEngine restricts and formats input as the user types:
var mask = UseMemo(() => new MaskEngine(MaskPreset.PhoneUS));
var (phone, setPhone) = UseState("");
return TextBox(phone, v => setPhone(mask.Apply(v)),
placeholderText: "(555) 555-0123");
Mask presets
| Preset |
Format |
MaskPreset.PhoneUS |
(___) ___-____ |
MaskPreset.SSN |
___-__-____ |
MaskPreset.ZipCode |
_____ |
MaskPreset.ZipCodePlus4 |
_____-____ |
MaskPreset.CreditCard |
____ ____ ____ ____ |
MaskPreset.Date |
__/__/____ |
Custom masks: new MaskEngine("AA-####") where A = letter,
# = digit, * = any.
7. Input formatters
InputFormatter applies format-as-you-type transformations:
var (amount, setAmount) = UseState("");
return TextBox(amount,
v => setAmount(InputFormatter.Currency(symbol: "$").Format(v)),
placeholderText: "$0.00");
| Formatter |
Effect |
InputFormatter.Currency(symbol: "$") |
$1,234.56 |
InputFormatter.PhoneUS |
(555) 555-0123 |
InputFormatter.UpperCase |
Force uppercase |
InputFormatter.LowerCase |
Force lowercase |
InputFormatter.TitleCase |
Title Case |
InputFormatter.MaxLength(n) |
Truncate at n chars |
InputFormatter.AllowOnly(regex) |
Whitelist characters |
Critical gotchas
- Always use controlled inputs —
(value, setter) pair. There is no
uncontrolled / two-way binding in Reactor.
- Call
validation.MarkAllTouched() before submit — when fields use the
.Validate(name, value, ...) form, validators run automatically every
render, but errors stay hidden until each field is touched. Mark all
registered fields touched on submit so error messages reveal at once,
then gate on validation.IsValid().
- Use
ShowWhen.WhenTouched (default) — showing errors immediately on
page load is hostile UX.
- MaskEngine and InputFormatter are different — masks restrict what
characters can be entered; formatters transform the display.
- Don't mix simple validation and UseValidationContext — pick one
approach per form.
- FormField handles layout and error display — don't manually build
error message TextBlocks when using FormField.
1---2name: reactor-forms3description: Reactor forms and validation — `UseValidationContext`, built-in validators (`Validate.Required`, `Validate.Email`, `Validate.MinLength`, etc.), `FormField` helper, masked input via `MaskEngine`, `InputFormatter`. Use when building data-entry screens, validation flows, or controlled-input forms.4---56# Forms and Validation in Reactor78Reactor forms use a **controlled-input pattern** — every input has an9explicit `(value, setter)` pair driven by `UseState`. There is no two-way10binding. Validation is layered on top via `UseValidationContext` and11declarative `.Validate()` modifiers.121314> **Controlled prop note:** factory call sites keep the plain `(value,15> setter)` shape, but the underlying element records use `Optional<T>`.16> Read element props with `.Value` / `.GetValueOrDefault(...)`; use17> `Optional<T>.Unset` only when the WinUI control should own the value. See18> [`migration/050-optional-t.md`](../../../../docs/guide/migration/050-optional-t.md).1920## Quick reference2122| API | Purpose |23|-----|---------|24| `TextBox(value, setValue)` | Controlled text input |25| `UseValidationContext()` | Track validation messages, touched/dirty state |26| `.Validate(...)` | Attach built-in validators to an input |27| `FormField(input, label: ...)` | Wraps input with label, error display, required marker |28| `new MaskEngine(...)` | Masked text input (phone, SSN, etc.) |29| `InputFormatter.Currency(...)` | Format-as-you-type |3031## 1. Controlled inputs3233Every input takes `(value, setter)`. State lives in the component:3435```csharp36var (name, setName) = UseState("");37var (age, setAge) = UseState(0);38var (agreed, setAgreed) = UseState(false);3940return VStack(12,41 TextBox(name, setName, placeholderText: "Name"),42 NumberBox(age, setAge),43 CheckBox(agreed, setAgreed, label: "I agree"),44 Button("Submit", onSubmit).IsEnabled(!(string.IsNullOrEmpty(name) || !agreed))45);46```4748### Available input types4950| Factory | Value type | Common modifiers |51|---------|-----------|------------------|52| `TextBox(value, setValue, placeholderText, header)` | `string` | `.Header()`, `.IsReadOnly()`, `.AcceptsReturn()`, `.TextWrapping()`, `.MaxLength(n)`, `.NumericInput()`, `.EmailInput()`, `.Changed(handler)` |53| `PasswordBox(password, setPassword, placeholderText)` | `string` | `.Header(text)`, `.MaxLength(n)`, `.PasswordChanged(handler)` |54| `NumberBox(value, setValue, header)` | `double` | `.PlaceholderText(text)`, `.Range(min, max)`, `.SpinButtons(...)` |55| `Slider(value, min, max, setValue)` | `double` | `.Header()`, `.StepFrequency()` |56| `ToggleSwitch(isOn, setIsOn, header, onContent, offContent)` | `bool` | `.Header()` |57| `CheckBox(isChecked, setIsChecked, label)` | `bool` | — |58| `RadioButtons(items, selected, setSelected, header)` | `int` | `.Set(rb => ...)` |59| `ComboBox(items, selected, setSelected)` | `object` | `.Header()`, `.IsEditable()`, `.PlaceholderText(text)` |60| `DatePicker(date, setDate, header)` | `DateTimeOffset` | `.Set(dp => ...)` |61| `TimePicker(time, setTime, header)` | `TimeSpan` | `.Set(tp => ...)` |62| `AutoSuggestBox(text, setText, onQuerySubmitted)` | `string` | `.PlaceholderText(text)`, `.Set(asb => ...)` |63| `RichEditBox(doc, setDoc, header)` | `string` | `.PlaceholderText(text)`, `.Set(reb => ...)` |64| `CalendarDatePicker(date, setDate)` | `DateTimeOffset?` | `.PlaceholderText(text)`, `.Set(cdp => ...)` |6566**Named-input shapes** (Spec 039 §17.3): `.NumericInput()` / `.EmailInput()`67preconfigure `InputScope` and IME hints so on-screen / soft keyboards open68in the right mode. Stack them with validators:6970```csharp71TextBox(email, setEmail, placeholderText: "you@example.com")72 .EmailInput()73 .MaxLength(254)74 .Validate("email", email, Validate.Required(), Validate.Email())75```7677For modifiers that aren't in the typed surface (e.g. `PasswordRevealMode`,78date min/max), use `.Set(native => ...)` to reach the underlying WinUI control.79The full catalog is in `references/reactor.api.txt`.8081## 2. Simple validation (derived booleans)8283For trivial forms, derive validation from state:8485```csharp86var (email, setEmail) = UseState("");87var isValid = email.Contains('@') && email.Length > 3;8889return VStack(12,90 TextBox(email, setEmail, placeholderText: "Email"),91 Button("Submit", onSubmit).IsEnabled(isValid)92);93```9495This is fine for 1–2 fields. For anything more, use `UseValidationContext`.9697## 3. UseValidationContext9899Tracks per-field validation messages, touched/dirty state, and overall100form validity:101102```csharp103var validation = this.UseValidationContext();104var (name, setName) = UseState("");105var (email, setEmail) = UseState("");106107return VStack(12,108 TextBox(name, setName, placeholderText: "Name")109 .Validate("name", name,110 Validate.Required("Name is required"),111 Validate.MinLength(2, "Name too short")),112113 TextBox(email, setEmail, placeholderText: "Email")114 .Validate("email", email,115 Validate.Required("Email is required"),116 Validate.Email("Invalid email")),117118 Button("Submit", () =>119 {120 validation.MarkAllTouched();121 if (validation.IsValid())122 Submit(name, email);123 })124);125```126127`.Validate(fieldName, value, ...)` resolves the surrounding `ValidationContext`128through React-style ambient context — you do not pass `validation` explicitly.129Passing the current value (the second arg) opts in to auto-validation as the130component re-renders; the validator-only overload `.Validate(fieldName, ...)`131is for cases where you trigger validation manually.132133### ValidationContext API134135| Member | Purpose |136|--------|---------|137| `.IsValid()` | `true` when no field has Error-severity messages |138| `.IsDirty()` | `true` when any registered field differs from initial value |139| `.IsDirty("field")` | Per-field dirty check |140| `.MarkAllTouched()` | Mark every registered field touched (typical on submit) |141| `.MarkTouched("field")` | Mark a single field touched |142| `.Reset("field")` | Reset one field to initial value, returns the initial |143| `.ResetAll()` | Reset all fields to initial values |144| `.ClearAll()` | Clear all messages (preserve touched/initial state) |145| `.GetMessages("field")` | Get error messages for a specific field |146| `.IsTouched("field")` | Whether the user has interacted with a field |147148## 4. Built-in validators149150The `.Validate()` modifier accepts an array of validators:151152| Validator | Purpose |153|-----------|---------|154| `Validate.Required(msg)` | Non-empty |155| `Validate.MinLength(n, msg)` | Minimum string length |156| `Validate.MaxLength(n, msg)` | Maximum string length |157| `Validate.Email(msg)` | Email format |158| `Validate.Match(pattern, msg)` | Custom regex pattern |159| `Validate.Range(min, max, msg)` | Numeric range |160| `Validate.Must<T>(predicate, msg)` | Arbitrary predicate |161| `Validate.EqualTo<T>(value, msg)` | Fields must match (confirm password) |162| `Validate.Url(msg)` | URL format |163| `Validate.MustBeTrue(msg)` | Boolean must be true (checkboxes) |164165## 5. FormField helper166167`FormField` wraps an input with a label, required marker, description168text, and error display:169170```csharp171var validation = this.UseValidationContext();172var (name, setName) = UseState("");173174return FormField(175 TextBox(name, setName, placeholderText: "Enter your name")176 .Validate("name", name, Validate.Required("Required")),177 label: "Full Name",178 required: true,179 description: "As it appears on your ID",180 showWhen: ShowWhen.WhenTouched // or Always, WhenDirty, AfterFirstSubmit, Never181);182```183184`ShowWhen` controls when error messages appear:185- `WhenTouched` — after the user has interacted with the field (recommended default)186- `Always` — immediately, even before user interaction187- `WhenDirty` — only after the value has changed188- `AfterFirstSubmit` — only after the first submit attempt189190## 6. Masked input191192`MaskEngine` restricts and formats input as the user types:193194```csharp195var mask = UseMemo(() => new MaskEngine(MaskPreset.PhoneUS));196var (phone, setPhone) = UseState("");197198return TextBox(phone, v => setPhone(mask.Apply(v)),199 placeholderText: "(555) 555-0123");200```201202### Mask presets203204| Preset | Format |205|--------|--------|206| `MaskPreset.PhoneUS` | `(___) ___-____` |207| `MaskPreset.SSN` | `___-__-____` |208| `MaskPreset.ZipCode` | `_____` |209| `MaskPreset.ZipCodePlus4` | `_____-____` |210| `MaskPreset.CreditCard` | `____ ____ ____ ____` |211| `MaskPreset.Date` | `__/__/____` |212213Custom masks: `new MaskEngine("AA-####")` where `A` = letter,214`#` = digit, `*` = any.215216## 7. Input formatters217218`InputFormatter` applies format-as-you-type transformations:219220```csharp221var (amount, setAmount) = UseState("");222223return TextBox(amount,224 v => setAmount(InputFormatter.Currency(symbol: "$").Format(v)),225 placeholderText: "$0.00");226```227228| Formatter | Effect |229|----------|--------|230| `InputFormatter.Currency(symbol: "$")` | `$1,234.56` |231| `InputFormatter.PhoneUS` | `(555) 555-0123` |232| `InputFormatter.UpperCase` | Force uppercase |233| `InputFormatter.LowerCase` | Force lowercase |234| `InputFormatter.TitleCase` | Title Case |235| `InputFormatter.MaxLength(n)` | Truncate at n chars |236| `InputFormatter.AllowOnly(regex)` | Whitelist characters |237238## Critical gotchas2392401. **Always use controlled inputs** — `(value, setter)` pair. There is no241 uncontrolled / two-way binding in Reactor.2422. **Call `validation.MarkAllTouched()` before submit** — when fields use the243 `.Validate(name, value, ...)` form, validators run automatically every244 render, but errors stay hidden until each field is touched. Mark all245 registered fields touched on submit so error messages reveal at once,246 then gate on `validation.IsValid()`.2473. **Use `ShowWhen.WhenTouched`** (default) — showing errors immediately on248 page load is hostile UX.2494. **MaskEngine and InputFormatter are different** — masks restrict what250 characters can be entered; formatters transform the display.2515. **Don't mix simple validation and UseValidationContext** — pick one252 approach per form.2536. **FormField handles layout and error display** — don't manually build254 error message TextBlocks when using FormField.