The custom validation allows you to apply rules that the standard DataAnnotations attributes do not cover.
These rules appear, for example, in situations like these:
- “The end date must be later than the start date.”
- “If the user is ‘Admin’, the phone number is required.”
- “The coupon code must exist in the database.”
In Blazor we can solve these with custom attributes, model validation, or messages added to the EditContext.
Custom Validation Attributes
This is the most elegant option if you want to reuse a rule across multiple models. It involves creating your own attribute by inheriting from ValidationAttribute.
Suppose we need to validate that a boolean is true (the classic “I accept the terms and conditions”). [Required] doesn’t work because false is a valid value for bool.
Let’s create the [MustBeTrue] attribute.
using System.ComponentModel.DataAnnotations;
public class MustBeTrueAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
// If the value is bool and it's true, everything is fine
if (value is bool booleanValue && booleanValue)
{
return ValidationResult.Success;
}
// If it fails, return the error (use the default message or a generic one)
return new ValidationResult(
ErrorMessage ?? "You must accept this condition",
[validationContext.MemberName!]);
}
}Now we can use it in our model as easily as the standard ones:
public class RegistrationModel
{
[MustBeTrue(ErrorMessage = "You must accept the terms to continue")]
public bool AcceptsTerms { get; set; }
}Cross-Field Validation with IValidatableObject
Attributes have a limitation: they only see their own property. They don’t know anything about the others.
How do we validate that “Password” and “Confirm Password” are equal? Or that EndDate > StartDate?
For this, the model can implement IValidatableObject. Its Validate method receives the full context and executes when the form is submitted, after property-level and type-level validations have completed without errors.
public class ReservationModel : IValidatableObject
{
[Required]
public DateTime CheckInDate { get; set; }
[Required]
public DateTime CheckOutDate { get; set; }
// This method executes at the end
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (CheckOutDate <= CheckInDate)
{
// Yield return allows returning multiple errors
yield return new ValidationResult(
"The check-out date must be later than the check-in date",
new[] { nameof(CheckOutDate) }); // Indicates which field to highlight in red
}
}
}Notice the second parameter of ValidationResult: new[] { nameof(CheckOutDate) }. This is crucial. It tells Blazor: “Hey, even though this error is general logic, I want you to paint the CheckOutDate input red and display the message in its <ValidationMessage>.”
Manual Validation with ValidationMessageStore
Some rules depend on the server. Classic example: The user fills out the form correctly, clicks “Submit”, and the database tells us: “The username already exists”.
We cannot detect that error before sending it. We need a way to inject errors into the form manually after an asynchronous response.
For this, we use the ValidationMessageStore.
@implements IDisposable
<EditForm EditContext="editContext" OnSubmit="HandleSubmit" FormName="create-user">
<DataAnnotationsValidator />
<label>Username:</label>
<InputText @bind-Value="model.UserName" class="form-control" />
<ValidationMessage For="@(() => model.UserName)" />
<button type="submit">Create</button>
</EditForm>
@code {
private readonly UserModel model = new();
private EditContext editContext = default!;
private ValidationMessageStore messageStore = default!;
protected override void OnInitialized()
{
// 1. Instantiate the EditContext manually
editContext = new(model);
// 2. Create the message store linked to that context
messageStore = new(editContext);
// 3. Clear old errors when the user modifies a field
editContext.OnFieldChanged += HandleFieldChanged;
}
private void HandleFieldChanged(object? sender, FieldChangedEventArgs e)
{
messageStore.Clear(e.FieldIdentifier);
editContext.NotifyValidationStateChanged();
}
private async Task HandleSubmit()
{
// 4. Remove previous remote errors and validate DataAnnotations
messageStore.Clear();
if (editContext.Validate())
{
// Simulate a server call
var exists = await UserService.UserExists(model.UserName);
if (exists)
{
// 5. INJECT THE ERROR MANUALLY
messageStore.Add(editContext.Field(nameof(model.UserName)),
"This username already exists");
// 6. Notify the UI to refresh the messages
editContext.NotifyValidationStateChanged();
}
else
{
// Save...
}
}
}
public void Dispose()
{
editContext.OnFieldChanged -= HandleFieldChanged;
}
}This approach requires managing the EditContext and its subscriptions, but it allows integrating errors returned by an API into the same validation components.
Observe the OnFieldChanged event. It is important to clear manual errors when the user starts typing again. Otherwise, the “Username already exists” message would remain forever, even if the user changes the name.
When to Use Each Strategy
So you don’t get lost, here is the decision guide:
| Scenario | Recommended Strategy |
|---|---|
| Simple rule (Email, Range, Required) | Standard DataAnnotations ([Required]) |
| Reusable business rule (DNI, Credit Card) | Custom Attribute (ValidationAttribute) |
| Compare two fields (Dates, Passwords) | IValidatableObject |
| Errors coming from API/Backend | ValidationMessageStore |