9.0 KiB
Form Validation
htmx-powered server-side validation that provides real-time per-field feedback on blur and full-form validation on submit — all without Blazor interactivity.
Architecture Overview
┌──────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌─────────────┐ blur ┌──────────────────────────┐ │
│ │ <TextInput> │ ──────► │ htmx POST /validate │ │
│ └─────────────┘ │ { _field: "email", │ │
│ │ email: "bad" } │ │
│ └──────────┬───────────────┘ │
│ │ │
│ ┌─────────────────┐ ◄──────────┘ │
│ │ <p data-field- │ HTML fragment: │
│ │ error="email"> │ <p class="text-destructive"> │
│ │ swapped by │ Please enter a valid email. │
│ │ htmx │ </p> │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Server (Minimal API) │
│ │
│ FormValidator.ValidateField("email", "bad") │
│ → "Please enter a valid email address." │
│ │
│ HtmxFormValidationRenderer.FieldErrorFragment(...) │
│ → HTML <p> element with error text │
└──────────────────────────────────────────────────────────┘
Step 1: Create a Validator
Define your validation rules by extending FormValidator and calling RuleFor() in the constructor:
using Enciphered.Blazor.UIComponents.Validation;
public class ContactFormValidator : FormValidator
{
public ContactFormValidator()
{
RuleFor("name",
displayName: "Name",
required: true,
minLength: 2);
RuleFor("email",
displayName: "Email",
required: true,
pattern: @".+@.+\..+",
message: "Please enter a valid email address.");
RuleFor("password",
displayName: "Password",
required: true,
minLength: 6);
RuleFor("age",
displayName: "Age",
min: 0,
max: 150);
RuleFor("birthdate",
displayName: "Birth Date",
custom: value => !DateOnly.TryParse(value, out _)
? "Please enter a valid date."
: null);
RuleFor("preferredtime",
displayName: "Preferred Time",
custom: value => !TimeOnly.TryParse(value, out _)
? "Please enter a valid time."
: null);
RuleFor("appointment",
displayName: "Appointment",
custom: value => !DateTime.TryParse(value, out _)
? "Please enter a valid date and time."
: null);
RuleFor("confirmation",
displayName: "Confirmation",
required: true,
custom: value => value != "CONFIRM"
? "You must type CONFIRM to proceed."
: null);
}
}
Step 2: Register Validation Endpoints
In Program.cs, call MapFormValidation<T>():
app.MapFormValidation<ContactFormValidator>("/api/forms/contact");
This registers two endpoints:
| Endpoint | Method | Purpose |
|---|---|---|
POST /api/forms/contact/validate |
Per-field | Validates a single field on blur |
POST /api/forms/contact/submit |
Full form | Validates all fields on submit |
Both endpoints have antiforgery disabled (via .DisableAntiforgery()) since htmx sends form data directly.
Step 3: Build the Form
Use HtmxForm, FormField, and input components:
<HtmxForm Endpoint="/api/forms/contact">
<FormField Label="Full Name" For="name">
<TextInput Id="name" Name="name" Placeholder="Jane Doe" />
</FormField>
<FormField Label="Email" For="email">
<TextInput Id="email" Name="email" Type="email" Placeholder="jane@example.com" />
</FormField>
<FormField Label="Password" For="password">
<TextInput Id="password" Name="password" Type="password" />
</FormField>
<FormField Label="Age" For="age">
<NumberInput Id="age" Name="age" Min="0" Max="150" />
</FormField>
<FormField Label="Birth Date" For="birthdate">
<DateInput Id="birthdate" Name="birthdate" />
</FormField>
<div class="flex gap-2 pt-2">
<Button Type="submit">Submit</Button>
<Button Type="reset" Variant="@ButtonVariant.Outline">Reset</Button>
</div>
</HtmxForm>
RuleFor API Reference
protected void RuleFor(
string field, // Form field name (must match the input's Name)
string? displayName, // Human-readable label (auto-generated from field if omitted)
bool required, // Whether the field is required
int? minLength, // Minimum string length
int? maxLength, // Maximum string length
string? pattern, // Regex pattern for format validation
double? min, // Minimum numeric value
double? max, // Maximum numeric value
string? message, // Custom error message for pattern failures
Func<string, string?>? custom // Custom validation function
);
Validation Order
Rules are evaluated in this order — the first failure stops evaluation:
- Required — empty/whitespace check
- Empty skip — if not required and value is empty, the field passes (skips remaining rules)
- MinLength — minimum character count
- MaxLength — maximum character count
- Pattern — regex match (uses
messageif provided, else default format error) - Min/Max — numeric range (attempts to parse as
double) - Custom — arbitrary validation function returning an error string or
null
Custom Validators
The custom parameter accepts a Func<string, string?> — receive the trimmed value, return an error message or null:
RuleFor("confirmation",
required: true,
custom: value => value != "CONFIRM"
? "You must type CONFIRM to proceed."
: null);
For date/time/datetime fields, use TryParse:
RuleFor("birthdate",
custom: value => !DateOnly.TryParse(value, out _)
? "Please enter a valid date."
: null);
Note
: Custom validators only run when the value is non-empty. If the field is not required and left blank, the custom function is never called.
How It Works
On Blur (Per-Field)
InputBase<T>auto-injects htmx attributes when insideHtmxForm+FormField- When the user leaves an input, htmx fires
POST /validatewith the field name and value - The server calls
FormValidator.ValidateField()and returns an HTML<p>fragment - htmx replaces the existing
<p data-field-error="...">element with the response
On Submit (Full Form)
HtmxFormaddshx-post="/submit"to the<form>element- htmx sends all form fields
- The server calls
FormValidator.ValidateAll()and returns:- If errors: OOB (out-of-band) swap fragments for each field's error element
- If valid: Success message + OOB swaps to clear all errors
HtmxForm Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
Endpoint |
string |
required | Base path (e.g. /api/forms/contact) |
ResultId |
string |
"form-result" |
ID of the result div for success/error messages |
Class |
string? |
— | Additional CSS classes on the <form> |
Form Reset
Clicking a <Button Type="reset"> triggers the browser's native form reset. The forms.js module listens for the reset event and:
- Clears all visible input values
- Hides all
[data-field-error]elements - Hides the result div
- Resets date/time trigger button text to their placeholders