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

Forms — validation

Inline, DataAnnotations, FluentValidation, and async validators for Rask forms.

‹ Back to Forms & validation

Inline validation

The lightest layer. Pass a Validate: lambda — per-field or per-form. Both accept a sync Func<…, IEnumerable<string>> or an async Func<…, CancellationToken, ValueTask<IEnumerable<string>>>; overload resolution picks by arity, no cast. An empty sequence means valid.


Form<LoginModel>(_model,
    OnValidSubmit: m => _submission = "Welcome",
    Validate: m => m.Password == m.Confirm ? [] : ["Passwords do not match."])[   // cross-field, at submit
    Input.Bind(() => _model.Email)
        .Validate(v => v.Contains('@') ? [] : ["Email looks wrong."]),             // per-field, per-keystroke
    ValidationMessage.For(() => _model.Email).Template(errs => Div.Class("err")[errs[0]]),
    ValidationSummary.Template(SummaryAlert),
    Button.Type("submit")["Sign in"]
]

Inline Validate: on a field or the whole form — no extra package. Return the error strings for the value; an empty result means valid.

InlineValidateDemo.cs

using Rask.Core.Forms;

namespace Rask.Site.Features;

public sealed partial class InlineValidateDemo : Component
{
    private readonly LoginModel _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])];

    private static Component? SummaryAlert(IReadOnlyList<ValidationEntry> entries)
    {
        // Filter to form-level entries — per-field rules already render through FieldError.
        var formOnly = entries.Where(e => e.Field.Length == 0).ToList();
        if (formOnly.Count == 0)
        {
            return null;
        }

        return Div.Class($"{Tw.AlertDanger} text-sm mb-0")[
            Ul.Class("mb-0 ps-3")[
                formOnly.Select((e, i) => Li.Key(i)[e.Message])
            ]
        ];
    }

    protected override Component? Render() =>
    [
        Form.Model(_model)
            .OnValidSubmit(m => _submission = $"Welcome, {m.Email}")
            .Class("flex flex-col gap-3")
            .Validate(m =>
                m.Password == m.Confirm ? Array.Empty<string>() : new[] { "Passwords do not match." })[
            Div[
                Label.For("v4-email").Class($"{Tw.Label} text-sm mb-1")["Email"],
                Input.Bind(() => _model.Email)
                    .Id("v4-email")
                    .Type(InputType.Email)
                    .Class(Tw.Input)
                    .Validate(v =>
                        v.Contains('@')
                            ? Array.Empty<string>()
                            : new[] { "Email looks wrong." }),
                ValidationMessage.Template(FieldError).For(() => _model.Email)
            ],
            Div[
                Label.For("v4-password").Class($"{Tw.Label} text-sm mb-1")["Password"],
                Input.Bind(() => _model.Password).Id("v4-password").Type(InputType.Password).Class(Tw.Input)
            ],
            Div[
                Label.For("v4-confirm").Class($"{Tw.Label} text-sm mb-1")["Confirm"],
                Input.Bind(() => _model.Confirm).Id("v4-confirm").Type(InputType.Password).Class(Tw.Input)
            ],
            ValidationSummary.Template(SummaryAlert),
            Div[
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.CheckCircle).Class("me-1"), "Sign in"]
            ]
        ],
        _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 LoginModel
{
    public string Email { get; set; } = "";
    public string Password { get; set; } = "";
    public string Confirm { get; set; } = "";
}
Live result

Per-field Validate: produces field-scoped messages and runs on each keystroke after the field is touched. Form-level Validate: runs at submit and attaches messages to the form-level slot (FieldIdentifier(model, "")) — they surface in ValidationSummary, never against a specific input.

An inline Validate: can also be async (Func<…, CancellationToken, ValueTask<IEnumerable<string>>>); the token cancels the in-flight check on the next keystroke:

InlineAsyncValidateDemo.cs

using Rask.Core.Forms;

namespace Rask.Site.Features;

public sealed partial class InlineAsyncValidateDemo : Component
{
    // Showcases the typed async Validate overload: a bare `async (v, ct) => …` lambda binds
    // directly to Func<TProp, CancellationToken, ValueTask<IEnumerable<string>>> on the Input,
    // and a bare `async (m, ct) => …` lambda binds the same shape on Form — both with no cast.
    // The 250ms delay drives the latest-wins cancellation path (rapid typing supersedes the
    // prior in-flight run) and ValidatingIndicator surfaces the pending state.
    private static readonly HashSet<string> TakenCodes =
        new(StringComparer.OrdinalIgnoreCase) { "BAD-001", "DEAD-BEEF", "RESERVED" };

    private readonly PromoModel _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])];

    private static Component Checking() =>
        Span.Class("validating-indicator text-ui-muted text-sm mt-1")[
            UiIcon.Name(UiIconName.Retry).Class("me-1"), "Checking…"
        ];

    private static Component? SummaryAlert(IReadOnlyList<ValidationEntry> entries)
    {
        var formOnly = entries.Where(e => e.Field.Length == 0).ToList();
        if (formOnly.Count == 0)
        {
            return null;
        }

        return Div.Class($"{Tw.AlertDanger} text-sm mb-0")[
            Ul.Class("mb-0 ps-3")[formOnly.Select((e, i) => Li.Key(i)[e.Message])]
        ];
    }

    private static async ValueTask<IEnumerable<string>> CheckCodeAsync(string code, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(code))
        {
            return Array.Empty<string>();
        }

        await Task.Delay(250, ct).ConfigureAwait(false);
        return TakenCodes.Contains(code) ? new[] { $"\"{code}\" is reserved." } : Array.Empty<string>();
    }

    protected override Component? Render() =>
    [
        Form.Model(_model).OnValidSubmit(m => _submission = $"Redeemed: {m.Code}").Class("flex flex-col gap-3").ValidateAsync(async (m, ct) =>
            {
                await Task.Yield();
                ct.ThrowIfCancellationRequested();
                return string.IsNullOrWhiteSpace(m.Code)
                    ? new[] { "Code is required." }
                    : Array.Empty<string>();
            })[
            Div[
                Label.For("v10-code").Class($"{Tw.Label} text-sm mb-1")["Promo code"],
                Input.Bind(() => _model.Code)
                    .Id("v10-code")
                    .Class(Tw.Input)
                    .ValidateAsync(CheckCodeAsync),
                ValidatingIndicator.Template(Checking).For(() => _model.Code),
                ValidationMessage.Template(FieldError).For(() => _model.Code)
            ],
            ValidationSummary.Template(SummaryAlert),
            Div[
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.Gift).Class("me-1"), "Redeem"]
            ]
        ],
        _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 PromoModel
{
    public string Code { get; set; } = "";
}
Live result

DataAnnotations

Put the attributes on the model. That is the whole setup — there is no package to add and nothing to declare in the form. Form<TModel> registers the pass itself, and one registration covers the whole reachable model graph.


public sealed class SignupModel
{
    [Required, StringLength(20, MinimumLength = 3)] public string Username { get; set; } = "";
    [Required, EmailAddress]                        public string Email    { get; set; } = "";
}

Form<SignupModel>(_model, OnValidSubmit: m => Console.WriteLine(m.Username))[
    Input.Bind(() => _model.Username),
    ValidationMessage.For(() => _model.Username).Template(errs => Div.Class("err")[errs[0]]),
    Input.Bind(() => _model.Email),
    ValidationMessage.For(() => _model.Email).Template(errs => Div.Class("err")[errs[0]]),
    Button.Type("submit")["Register"]
]

Per-field DataAnnotations attributes with a ValidationMessage under each input — the message appears once the field is touched and clears when it becomes valid.

ValidationFieldsDemo.cs

using Rask.Core.Forms;

namespace Rask.Site.Features;

public sealed partial class ValidationFieldsDemo : Component
{
    private readonly RegistrationModel _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 = $"Registered: {m.Name} <{m.Email}>").Class("flex flex-col gap-3")[
            Div[
                Label.For("v1-name").Class($"{Tw.Label} text-sm mb-1")["Name"],
                Input.Bind(() => _model.Name).Id("v1-name").Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Name)
            ],
            Div[
                Label.For("v1-email").Class($"{Tw.Label} text-sm mb-1")["Email"],
                Input.Bind(() => _model.Email)
                    .Id("v1-email")
                    .Type(InputType.Email)
                    .Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Email)
            ],
            Div[
                Label.For("v1-age").Class($"{Tw.Label} text-sm mb-1")["Age"],
                Input.Bind(() => _model.Age).Id("v1-age").Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Age)
            ],
            Div[
                Label.For("v1-plan").Class($"{Tw.Label} text-sm mb-1")["Plan"],
                Select.Bind(() => _model.Plan).Id("v1-plan").Class(Tw.Select)[
                    Option.Value("")["— choose —"],
                    Option.Value("free")["Free"],
                    Option.Value("pro")["Pro"],
                    Option.Value("team")["Team"]
                ],
                ValidationMessage.Template(FieldError).For(() => _model.Plan)
            ],
            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

Supports [Required], [EmailAddress], [Range], [StringLength], [RegularExpression], custom ValidationAttribute subclasses, and IValidatableObject. Unlike the BCL's Validator.TryValidateObject, Rask invokes IValidatableObject.Validate even when attribute errors exist — so attribute and object-level errors surface together (ASP.NET Core MVC parity). The ValidationContext is built with the render-scoped IServiceProvider, so custom attributes can call ctx.GetService<T>().

A ValidationResult with empty MemberNames lands on the form-level slot (ValidationSummary); a populated one tags the named field.

A custom ValidationAttribute (with DI via ctx.GetService<T>()):

CustomAttributeDemo.cs

using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;

namespace Rask.Site.Features;

// Custom ValidationAttribute showcase. Three flavors flow through the built-in pass
// unchanged because System.ComponentModel.DataAnnotations.Validator walks every attribute on the
// property — there's no opt-in needed for user-authored subclasses:
//   • StrongPassword overrides IsValid(object?) — the simplest shape.
//   • MatchesProperty overrides GetValidationResult(object?, ValidationContext) — uses
//     ValidationContext.ObjectInstance to do cross-field comparison.
//   • NotBanned overrides GetValidationResult and resolves IBannedWordService via
//     ValidationContext.GetService<T>() — proves the render-scoped IServiceProvider flows through.
public sealed partial class CustomAttributeDemo : Component
{
    private readonly CustomAttributeModel _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 = $"Welcome, {m.Username}!").Class("flex flex-col gap-3")[
            Div[
                Label.For("v12-username").Class($"{Tw.Label} text-sm mb-1")["Username"],
                Input.Bind(() => _model.Username).Id("v12-username").Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Username)
            ],
            Div[
                Label.For("v12-password").Class($"{Tw.Label} text-sm mb-1")["Password"],
                Input.Bind(() => _model.Password).Id("v12-password").Type(InputType.Password).Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Password)
            ],
            Div[
                Label.For("v12-confirm").Class($"{Tw.Label} text-sm mb-1")["Confirm password"],
                Input.Bind(() => _model.ConfirmPassword).Id("v12-confirm").Type(InputType.Password).Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.ConfirmPassword)
            ],
            Div[
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.ShieldOk).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 CustomAttributeModel
{
    [Required(ErrorMessage = "Username is required.")]
    [NotBanned(ErrorMessage = "\"{0}\" isn't available.")]
    public string Username { get; set; } = "";

    [Required(ErrorMessage = "Password is required.")]
    [StrongPassword(ErrorMessage = "Password must be at least 8 characters and mix letters and digits.")]
    public string Password { get; set; } = "";

    [Required(ErrorMessage = "Please confirm your password.")]
    [MatchesProperty(nameof(Password), ErrorMessage = "Passwords don't match.")]
    public string ConfirmPassword { get; set; } = "";
}

[AttributeUsage(AttributeTargets.Property)]
public sealed class StrongPasswordAttribute : ValidationAttribute
{
    public override bool IsValid(object? value)
    {
        if (value is not string s || s.Length < 8)
        {
            return false;
        }

        bool hasLetter = false, hasDigit = false;
        foreach (var ch in s)
        {
            if (char.IsLetter(ch))
            {
                hasLetter = true;
            }
            else if (char.IsDigit(ch))
            {
                hasDigit = true;
            }

            if (hasLetter && hasDigit)
            {
                return true;
            }
        }

        return false;
    }
}

[AttributeUsage(AttributeTargets.Property)]
public sealed class MatchesPropertyAttribute(string otherProperty) : ValidationAttribute
{
    public string OtherProperty { get; } = otherProperty;

    [UnconditionalSuppressMessage("Trimming", "IL2075",
        Justification =
            "GetProperty on the model's runtime type — the model is preserved by the user's binding setup, same contract as the validator itself.")]
    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        var instance = validationContext.ObjectInstance;
        var sibling = instance.GetType().GetProperty(OtherProperty);
        if (sibling is null)
        {
            return new ValidationResult($"Unknown property '{OtherProperty}'.");
        }

        var other = sibling.GetValue(instance);
        return Equals(value, other)
            ? ValidationResult.Success
            : new ValidationResult(ErrorMessage ?? $"Must match {OtherProperty}.",
                validationContext.MemberName is null ? null : new[] { validationContext.MemberName });
    }
}

[AttributeUsage(AttributeTargets.Property)]
public sealed class NotBannedAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        // No SP, no enforcement — the rule degrades gracefully when the host hasn't registered
        // the service. ASP.NET Core MVC's own attributes behave the same way when GetService
        // returns null. This means tests that bypass the live render path see the attribute
        // pass for any value; the dedicated DI test pushes a LiveRenderContext to opt in.
        var svc = (IBannedWordService?)validationContext.GetService(typeof(IBannedWordService));
        if (svc is null || value is not string s || s.Length == 0)
        {
            return ValidationResult.Success;
        }

        return svc.Words.Contains(s)
            ? new ValidationResult(FormatErrorMessage(s),
                validationContext.MemberName is null ? null : new[] { validationContext.MemberName })
            : ValidationResult.Success;
    }
}
Live result

IValidatableObject runs alongside the attributes, model-level:

ValidatableObjectDemo.cs

using System.ComponentModel.DataAnnotations;
using Rask.Core.Forms;

namespace Rask.Site.Features;

// IValidatableObject parity with ASP.NET Core: BookingModel mixes attribute rules ([Required]
// on Name) with an IValidatableObject.Validate method that yields both a per-field result
// (MemberNames=[nameof(Departure)]) and a form-level result (no MemberNames). The BCL's own
// Validator.TryValidateObject would silence Validate() once the attribute fails — Rask's
// built-in pass calls IValidatableObject directly so all errors accumulate.
public sealed partial class ValidatableObjectDemo : Component
{
    private readonly BookingModel _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])];

    private static Component? SummaryAlert(IReadOnlyList<ValidationEntry> entries)
    {
        var formOnly = entries.Where(e => e.Field.Length == 0).ToList();
        if (formOnly.Count == 0)
        {
            return null;
        }

        return Div.Class($"{Tw.AlertDanger} text-sm mb-0")[
            Ul.Class("mb-0 ps-3")[formOnly.Select((e, i) => Li.Key(i)[e.Message])]
        ];
    }

    protected override Component? Render() =>
    [
        Form.Model(_model).OnValidSubmit(m => _submission = $"Booked: {m.Name} {m.Departure:yyyy-MM-dd} → {m.Arrival:yyyy-MM-dd}").Class("flex flex-col gap-3")[
            ValidationSummary.Template(SummaryAlert),
            Div[
                Label.For("v11-name").Class($"{Tw.Label} text-sm mb-1")["Name"],
                Input.Bind(() => _model.Name).Id("v11-name").Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Name)
            ],
            Div[
                Label.For("v11-departure").Class($"{Tw.Label} text-sm mb-1")["Departure"],
                Input.Bind(() => _model.Departure).Id("v11-departure").Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Departure)
            ],
            Div[
                Label.For("v11-arrival").Class($"{Tw.Label} text-sm mb-1")["Arrival"],
                Input.Bind(() => _model.Arrival).Id("v11-arrival").Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Arrival)
            ],
            Div[
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.Calendar).Class("me-1"), "Book"]
            ]
        ],
        _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 BookingModel : IValidatableObject
{
    private static readonly DateOnly Today = new(2026, 5, 14);

    [Required(ErrorMessage = "Name is required.")]
    public string Name { get; set; } = "";

    public DateOnly Departure { get; set; } = new(2026, 7, 1);
    public DateOnly Arrival { get; set; } = new(2026, 7, 5);

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (Departure < Today)
        {
            yield return new ValidationResult(
                "Departure cannot be in the past.",
                new[] { nameof(Departure) });
        }

        if (Arrival <= Departure)
        {
            yield return new ValidationResult("Arrival must be after departure.");
        }
    }
}
Live result

Turning it off

Absence of code is the meaning here: a form that says nothing about validation validates. Only the deviation is written.


Form.Model(_model).AutoValidate(false)[ … ]   // this form only

app.Configure(c => c.Validation.Off());       // the whole app
RaskValidation.AutoValidate = false;          // the same switch, without the Rask package

The global off wins — a form cannot opt back in.


FluentValidation

Writing the validator is the registration. A generator finds every AbstractValidator<T> in your app at compile time, and a Form<T> asks for the one that validates its model — so there is nothing to declare in the form and nothing to wire in Program.cs. It is wrapped as an IAsyncFieldValidator, so async MustAsync rules work exactly like synchronous ones.

There is no assembly scan anywhere in this: registration is emitted as a [ModuleInitializer], which is what lets a WebAssembly app use FluentValidation and still publish trimmed.


public sealed class OrderValidator : AbstractValidator<OrderModel>
{
    public OrderValidator()
    {
        RuleFor(x => x.Product).NotEmpty();
        RuleFor(x => x.Quantity).GreaterThanOrEqualTo(1).WithMessage("Quantity must be at least 1.");
    }
}

Form<OrderModel>(_model, m => _submission = "Ordered")[
    Input.Bind(() => _model.Product),
    ValidationMessage.For(() => _model.Product).Template(errs => Div.Class("err")[errs[0]]),
    Input.Bind(() => _model.Quantity),
    ValidationMessage.For(() => _model.Quantity).Template(errs => Div.Class("err")[errs[0]]),
    Button.Type("submit")["Order"]
]

An AbstractValidator<TModel>, discovered at compile time and run by the form with nothing declared — the RuleFor chains drive the same ValidationMessage/ValidationSummary UI.

FluentValidationDemo.cs

using FluentValidation;

namespace Rask.Site.Features;

public sealed partial class FluentValidationDemo : Component
{
    private readonly OrderModel _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 = $"Ordered {m.Quantity} × {m.Product}").Class("flex flex-col gap-3")[
            Div[
                Label.For("v7-product").Class($"{Tw.Label} text-sm mb-1")["Product"],
                Input.Bind(() => _model.Product).Id("v7-product").Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Product)
            ],
            Div[
                Label.For("v7-quantity").Class($"{Tw.Label} text-sm mb-1")["Quantity"],
                Input.Bind(() => _model.Quantity).Id("v7-quantity").Class(Tw.Input),
                ValidationMessage.Template(FieldError).For(() => _model.Quantity)
            ],
            Div[
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.ShoppingBag).Class("me-1"), "Order"]
            ]
        ],
        _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 OrderModel
{
    public string Product { get; set; } = "";
    public int Quantity { get; set; }
}

public sealed class OrderValidator : AbstractValidator<OrderModel>
{
    public OrderValidator()
    {
        RuleFor(x => x.Product).NotEmpty().WithMessage("Product is required.");
        RuleFor(x => x.Quantity).GreaterThanOrEqualTo(1).WithMessage("Quantity must be at least 1.");
    }
}
Live result

Per-keystroke validation on a root-model field scopes FluentValidation to that single property (MemberNameValidatorSelector, fast path); submit runs every rule. FluentValidation's own Cascade(CascadeMode.Stop) mirrors Rask's first-error-wins gating.

An async MustAsync rule rides the same wrapper:

FluentValidationAsyncDemo.cs

using FluentValidation;

namespace Rask.Site.Features;

// FluentValidation async: a single RuleFor chain stacks NotEmpty → Matches → MustAsync.
// FluentValidationValidator wraps the whole IValidator into an IAsyncFieldValidator, so
// MustAsync awaits the network-shaped check and the ValidatingIndicator surfaces while
// the await is in flight.
public sealed partial class FluentValidationAsyncDemo : Component
{
    private readonly TicketModel _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])];

    private static Component Checking() =>
        Span.Class("validating-indicator text-ui-muted text-sm mt-1")[
            UiIcon.Name(UiIconName.Retry).Class("me-1"), "Checking availability..."
        ];

    protected override Component? Render() =>
    [
        Form.Model(_model).OnValidSubmit(m => _submission = $"Reserved: {m.Code}").Class("flex flex-col gap-3")[
            Div[
                Label.For("v9-code").Class($"{Tw.Label} text-sm mb-1")["Ticket code"],
                Input.Bind(() => _model.Code).Id("v9-code").Class(Tw.Input),
                ValidatingIndicator.Template(Checking).For(() => _model.Code),
                ValidationMessage.Template(FieldError).For(() => _model.Code)
            ],
            Div[
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.Ticket).Class("me-1"), "Reserve"]
            ]
        ],
        _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 TicketModel
{
    public string Code { get; set; } = "";
}

// CascadeMode.Stop keeps FV's own chain aligned with Rask's first-error-wins gating:
// NotEmpty must pass before Matches runs, which must pass before MustAsync fires.
public sealed class TicketValidator : AbstractValidator<TicketModel>
{
    private static readonly HashSet<string> Used = new(StringComparer.OrdinalIgnoreCase)
    {
        "TKT-001", "TKT-002", "TKT-003"
    };

    public TicketValidator()
    {
        RuleFor(x => x.Code).Cascade(CascadeMode.Stop)
            .NotEmpty().WithMessage("Code is required.")
            .Matches(@"^TKT-\d{3}$").WithMessage("Format must be TKT-123.")
            .MustAsync(async (code, ct) =>
            {
                await Task.Delay(400, ct).ConfigureAwait(false);
                return !Used.Contains(code);
            }).WithMessage("Code is already reserved.");
    }
}
Live result

A validator that needs services

A uniqueness rule has to ask something. Declare the dependency on the constructor and it is resolved from the render scope — the generator reads the constructor and builds the validator for you:


public sealed class OrderValidator : AbstractValidator<OrderModel>
{
    public OrderValidator(IProductCatalog catalog) =>
        RuleFor(x => x.Product)
            .MustAsync(async (sku, ct) => await catalog.ExistsAsync(sku, ct))
            .WithMessage("No such product.");
}

One public constructor is the rule. Several leaves no way to choose, which is RASKVAL002; two validators for one model is RASKVAL001.

Both passes run, attributes first

A model can carry [Required] and have an AbstractValidator<T>. Both run: DataAnnotations is the sync stage and the discovered validator the async one, so the existing pipeline order already puts attributes first, and per-field first-error-wins means an attribute message shadows a FluentValidation one on the same field. Nothing was reordered to make this work.


Async validators and the validating indicator

Three ways to validate asynchronously:

  1. Inline async Validate: — return a ValueTask<IEnumerable<string>>. The CancellationToken cancels the in-flight check on the next keystroke (latest-wins).

  2. IAsyncFieldValidator — reach for this when the rule needs DI (an HttpClient, a repository) or you want to reuse it across forms. Add it to an EditContext you own:

    
    public sealed class UniqueUsernameValidator : IAsyncFieldValidator
    {
        public async ValueTask ValidateFieldAsync(EditContext ctx, FieldIdentifier field, CancellationToken ct)
        {
            if (ctx.Model is SignupModel m && field.FieldName == nameof(SignupModel.Username))
            {
                await Task.Delay(400, ct);            // pretend it's an API call
                if (await IsTakenAsync(m.Username))
                    ctx.AddValidationMessage(field, "Already taken.");
            }
        }
        public ValueTask ValidateAsync(EditContext c, CancellationToken ct) => default;
    }
    
    _ctx = new EditContext(_model);
    _ctx.AddValidator(new UniqueUsernameValidator());
    // Form<…>(_model, Context: _ctx)[ … ]
    
  3. FluentValidation MustAsync — async rules ride the discovered validator, which is wrapped as an IAsyncFieldValidator.

Each await in a handler triggers a re-render, so a ValidatingIndicator can surface while a check is in flight:


ValidatingIndicator.For(() => _model.Username).Template(() => Span.Class("spinner")["Checking…"])

An IAsyncFieldValidator (the username-uniqueness check above) with the validating indicator:

AsyncValidationDemo.cs

using System.ComponentModel.DataAnnotations;
using Rask.Core.Forms;

namespace Rask.Site.Features;

public sealed partial class AsyncValidationDemo : Component
{
    private readonly EditContext _ctx;
    private readonly SignupModel _model = new();
    private string? _submission;

    public AsyncValidationDemo()
    {
        _ctx = new EditContext(_model);
        _ctx.AddValidator(new UniqueUsernameValidator());
    }

    private static Component Checking() =>
        Span.Class("validating-indicator text-ui-muted text-sm mt-1")[
            UiIcon.Name(UiIconName.Retry).Class("me-1"), "Checking availability..."
        ];

    protected override Component? Render() =>
    [
        Form.Model(_model).OnValidSubmit(m => _submission = $"Signed up: {m.Username}").Context(_ctx).Class("flex flex-col gap-3")[
            Div[
                Label.For("v3-username").Class($"{Tw.Label} text-sm mb-1")["Username"],
                Input.Bind(() => _model.Username).Id("v3-username").Class(Tw.Input),
                ValidatingIndicator.Template(Checking).For(() => _model.Username),
                ValidationMessage.Template(msgs => [.. msgs.Select((m, i) => Div.Key(i).Class("text-danger text-sm mt-1")[m])])
                    .For(() => _model.Username)
            ],
            Div[
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.CheckCircle).Class("me-1"), "Sign up"]
            ]
        ],
        _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 SignupModel
{
    [Required(ErrorMessage = "Username is required.")]
    [StringLength(20, MinimumLength = 3, ErrorMessage = "Username must be 3–20 characters.")]
    public string Username { get; set; } = "";
}

public sealed class UniqueUsernameValidator : IAsyncFieldValidator
{
    private static readonly HashSet<string> Taken = new(StringComparer.OrdinalIgnoreCase) { "admin", "taken", "root" };

    public async ValueTask ValidateAsync(EditContext context, CancellationToken cancellationToken)
    {
        if (context.Model is SignupModel m)
        {
            await CheckAsync(context, new FieldIdentifier(m, nameof(SignupModel.Username)), m.Username,
                cancellationToken).ConfigureAwait(false);
        }
    }

    public async ValueTask ValidateFieldAsync(EditContext context, FieldIdentifier field,
        CancellationToken cancellationToken)
    {
        if (context.Model is SignupModel m && field.FieldName == nameof(SignupModel.Username))
        {
            await CheckAsync(context, field, m.Username, cancellationToken).ConfigureAwait(false);
        }
    }

    private static async Task CheckAsync(EditContext context, FieldIdentifier field, string username,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(username))
        {
            return;
        }

        // E2E test seam: the literal "explode" forces the validator to throw mid-await so the
        // framework's generic "Validation could not be completed." path is exercised end-to-end.
        if (string.Equals(username, "explode", StringComparison.OrdinalIgnoreCase))
        {
            await Task.Yield();
            throw new InvalidOperationException("Simulated remote failure.");
        }

        await Task.Delay(400, ct).ConfigureAwait(false);
        if (Taken.Contains(username))
        {
            context.AddValidationMessage(field, $"\"{username}\" is already taken.");
        }
    }
}
Live result

Validation can also be driven programmaticallyEditContext.Validate() and reading IsValidating:

ProgrammaticValidateDemo.cs

using System.ComponentModel.DataAnnotations;
using Rask.Core.Forms;

namespace Rask.Site.Features;

public sealed partial class ProgrammaticValidateDemo : Component
{
    private readonly EditContext _ctx;
    private readonly TaskModel _model = new();
    private string? _submission;

    public ProgrammaticValidateDemo()
    {
        _ctx = new EditContext(_model);
        _ctx.AddValidator(new SlowTitleValidator());
    }

    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…"
        ];

    private async Task ValidateNowAsync() => await _ctx.ValidateAsync().ConfigureAwait(false);

    protected override Component? Render() =>
    [
        Form.Model(_model).OnValidSubmit(m => _submission = $"Saved task: {m.Title}").Context(_ctx).Class("flex flex-col gap-3")[
            Div[
                Label.For("v6-title").Class($"{Tw.Label} text-sm mb-1")["Title"],
                Input.Bind(() => _model.Title).Id("v6-title").Class(Tw.Input),
                ValidatingIndicator.Template(Checking).For(() => _model.Title),
                ValidationMessage.Template(FieldError).For(() => _model.Title)
            ],
            Div.Class("flex gap-2 flex-wrap items-center")[
                Button.Type("button").Class(Tw.BtnOutlineSecondary).Id("v6-validate-now").OnClickAsync(ValidateNowAsync)[
                    UiIcon.Name(UiIconName.Search).Class("me-1"), "Validate now"
                ],
                Button.Class(Tw.BtnPrimary).Type("submit").Id("v6-submit").Disabled(_ctx.IsValidatingAny)[UiIcon.Name(UiIconName.CheckCircle).Class("me-1"), "Save"]
            ]
        ],
        _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 TaskModel
{
    [Required(ErrorMessage = "Title is required.")]
    public string Title { get; set; } = "";
}

// 600ms delay so the e2e test for submit-disable has a deterministic window to observe
// the disabled state before the async validator settles. Like UniqueUsernameValidator,
// the literal "explode" exercises the framework's exception fallback.
public sealed class SlowTitleValidator : IAsyncFieldValidator
{
    public async ValueTask ValidateAsync(EditContext context, CancellationToken cancellationToken)
    {
        if (context.Model is TaskModel m)
        {
            await CheckAsync(context, new FieldIdentifier(m, nameof(TaskModel.Title)), m.Title, cancellationToken)
                .ConfigureAwait(false);
        }
    }

    public async ValueTask ValidateFieldAsync(EditContext context, FieldIdentifier field,
        CancellationToken cancellationToken)
    {
        if (context.Model is TaskModel m && field.FieldName == nameof(TaskModel.Title))
        {
            await CheckAsync(context, field, m.Title, cancellationToken).ConfigureAwait(false);
        }
    }

    private static async Task CheckAsync(EditContext context, FieldIdentifier field, string title, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(title))
        {
            return;
        }

        await Task.Delay(600, ct).ConfigureAwait(false);
        if (string.Equals(title, "duplicate", StringComparison.OrdinalIgnoreCase))
        {
            context.AddValidationMessage(field, $"\"{title}\" is already used.");
        }
    }
}
Live result

IsValidating vs ShouldShowValidatingIndicator

  • EditContext.IsValidating(field) / IsValidatingAny — the exact "a validator is in flight right now" answer. Use it for control flow (e.g. Disabled: _ctx.IsValidatingAny on a submit button).
  • ShouldShowValidatingIndicator(field)IsValidating extended with a short sticky tail (EditContext.ValidatingStickyMs, default 200ms). A sub-second check still reads as "showing" for the sticky window so the indicator has a footprint screen-readers and Playwright can observe. This is what ValidatingIndicator renders against. The sticky dismissal is a single timer-driven re-render at window expiry. Set ValidatingStickyMs = 0 on a context you own to opt out; it does not delay submit or validator completion.

First-error-wins

The pipeline runs inline → form-level inline → sync IFieldValidator → async IAsyncFieldValidator. Once any stage flags a field, later stages stay quiet on that same field — so fixing one error reveals the next rule's message. A validator that throws mid-check surfaces a generic "Validation could not be completed." rather than killing the submit pipeline.

FirstErrorWinsDemo.cs

using System.ComponentModel.DataAnnotations;

namespace Rask.Site.Features;

// First-error-wins: an inline per-field rule and a DataAnnotations rule both target the
// same field. EditContext gates later stages once any earlier stage has flagged the field,
// so the inline "Required." message appears while the input is empty, and ONLY after that
// rule passes does the [RegularExpression] format error surface.
public sealed partial class FirstErrorWinsDemo : Component
{
    private readonly LicenseModel _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 = $"Activated: {m.Code}").Class("flex flex-col gap-3")[
            Div[
                Label.For("v8-code").Class($"{Tw.Label} text-sm mb-1")["License code"],
                Input.Bind(() => _model.Code)
                    .Id("v8-code")
                    .Class(Tw.Input)
                    .Validate(v =>
                        string.IsNullOrWhiteSpace(v)
                            ? new[] { "Code is required." }
                            : Array.Empty<string>()),
                ValidationMessage.Template(FieldError).For(() => _model.Code)
            ],
            Div[
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.Unlock).Class("me-1"), "Activate"]
            ]
        ],
        _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 LicenseModel
{
    [RegularExpression(@"^[A-Z]{3}-\d{3}$", ErrorMessage = "Use the ABC-123 format.")]
    public string Code { get; set; } = "";
}
Live result

A cross-field rule (form-level Validate: feeding the ValidationSummary):

CrossFieldSummaryDemo.cs

using Rask.Core.Forms;

namespace Rask.Site.Features;

public sealed partial class CrossFieldSummaryDemo : Component
{
    private readonly TripModel _model = new();
    private string? _submission;

    private static Component? SummaryAlert(IReadOnlyList<ValidationEntry> entries) =>
        entries.Count == 0
            ? null
            : Div.Class($"{Tw.AlertDanger} text-sm mb-0")[
                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 = $"Booked: {m.Depart:yyyy-MM-dd} → {m.Return:yyyy-MM-dd}")
            .Class("flex flex-col gap-3")
            .Validate(m =>
                m.Return > m.Depart
                    ? Array.Empty<string>()
                    : new[] { "Return date must be after departure." })[
            ValidationSummary.Template(SummaryAlert),
            Div[
                Label.For("v5-depart").Class($"{Tw.Label} text-sm mb-1")["Departure"],
                Input.Bind(() => _model.Depart).Id("v5-depart").Class(Tw.Input)
            ],
            Div[
                Label.For("v5-return").Class($"{Tw.Label} text-sm mb-1")["Return"],
                Input.Bind(() => _model.Return).Id("v5-return").Class(Tw.Input)
            ],
            Div[
                Button.Class(Tw.BtnPrimary).Type("submit")[UiIcon.Name(UiIconName.PaperAirplane).Class("me-1"), "Book"]
            ]
        ],
        _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 TripModel
{
    public DateOnly Depart { get; set; } = new(2026, 6, 1);
    public DateOnly Return { get; set; } = new(2026, 6, 1);
}
Live result