ASP.NET forms validation and htmx

ASP.NET forms validation and htmx

Exploring form validation in ASP.NET Razor Pages with and without htmx

Htmx works great with ASP.NET Razor Pages. Let's explore how we can solve the most annoying part of any form post: client-side and server-side validations.

We will explore built-in validation options, how we can adapt ASPNET's built-in validation to work efficiently with htmx, or how we can skip it completely and use HTML5 for validation.

To understand this article, you should already be familiar with Razor Pages and htmx. If not, I recommend reading a more introductory article in the series.

History of validation in ASPNET

In ASP-NET applications, validation has been built right into the framework using Data Annotations and unobtrusive JavaScript since early versions. You can define validation rules in your model classes with attributes like [Required], [StringLength], or [Range]. These rules would be used on both the server and client sides.

For client-side validation, the jquery.validate.unobtrusive library steps in. It applies validation rules using data attributes in the HTML elements. The Razor Pages engine uses tag helpers (like asp-for) to generate input tags with all the necessary attributes.

<!-- generated input with validation for use with jquery.validate.unobtrusive -->
<label for="FirstName">First Name</label>
<input type="text" data-val="true" 
    data-val-length="First Name cannot be longer than 50 characters" 
    data-val-length-max="50" 
    data-val-required="First Name is required" 
    id="FirstName" 
    maxlength="50" 
    name="FirstName" value="">

The jquery.validate.unobtrusive library was introduced with the ASP.NET MVC 3 release on January 13, 2011. In ASP.NET MVC 2, released in March 2010, server-side validation was more common. While client-side validation was possible, it required more manual setup. Developers had to use the jQuery Validation plugin and write custom JavaScript code for the validation logic.

For server-side validation, user-submitted data must follow certain rules before it can be processed. The rules are defined using Data Annotations in model classes. During form submission, the model binding process maps the form data to the model properties and runs the validation framework.


public record UserRegistrationForm
{
    // annotatied property
    [Required(ErrorMessage = "First Name is required")]
    [StringLength(50, ErrorMessage = "First Name cannot be longer than 50 characters")]
    [Display(Name = "First Name")]
    public string FirstName { get; init; }
}

If any data doesn't meet the defined rules, the framework sets the ModelState to invalidate and record the validation errors. This information could be used to build a response page and display server-side validation errors on the form.

Project set-up

To test different types of validation, I created a simple Razor Pages project using pico CSS as the default design library. I did not use Bootstrap or any third-party JavaScript libraries except for jquery, jquery.validation, and htmx.

Each case has a separate page with the same partial _FormContent for the form's internals. We can reuse the same partial and almost the same page models for any type of validation: htmx, non-htmx, HTML5, and jquery.validation.

@page
@model Xakpc.RazorHtmx.Validation.Pages.RazorPageFormModel

@{
    ViewData["Title"] = "User Registration";
}

<hgroup>
    <h2>User Registration - Unobtrusive</h2>
    <p>Regular POST of the form with <mark>jquery.validate.unobtrusive</mark> validation</p>
</hgroup>

<article>
    <form method="post">
        <partial name="_FormContent" model="@Model.UserRegistration"/>
    </form>
</article>

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

The page model would be also almost the same for each validation: a bound property and a single OnPost handler.

    public class RazorPageFormModel : PageModel
    {
        [BindProperty]
        public UserRegistrationForm UserRegistration { get; set; }

        public void OnGet()
        {
        }

        public IActionResult OnPost()
        {
            if (User.PhoneNumber.Length < 4) {
                ModelState.AddModelError("PhoneNumber", "Phone format is wrong");   
            }

            if (!ModelState.IsValid)
            {
                return Page();
            }

            // Store the user data in TempData
            TempData["User"] = JsonSerializer.Serialize(UserRegistration);
            return RedirectToPage("Success");
        }
    }

Fancy Validation States

Pico CSS supports some fancy validation states using the aria-invalid attribute. All pages include custom code to set this attribute during server-side validation and also on the client side for HTML5 validation, as it does not do this by default.

aria-invalid=true

Managing validation state on the server side is done using TagHelpers, which are convenient and powerful tools for generating HTML. I use a set of tag helpers in my Razor Pages projects, both with and without htmx.

Specifically, the ValidationStateInputTagHelper is applied to all input tags where the asp-for attribute is present. It adds the aria-invalid attribute based on the value and error state.

[HtmlTargetElement("input", Attributes = "asp-for")]
[HtmlTargetElement("select", Attributes = "asp-for")]
public class ValidationStateInputTagHelper : TagHelper
{
    [HtmlAttributeNotBound]
    [ViewContext]
    public ViewContext ViewContext { get; set; }

    [HtmlAttributeName("asp-for")]
    public ModelExpression For { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        var model = For.Model;

        // Check if the field has a value
        bool hasValue = model != null && !string.IsNullOrEmpty(model.ToString());

        // Check if there are any validation errors for this field
        var modelState = ViewContext;
        bool hasError = modelState != null &&
                        modelState.ViewData.ModelState.TryGetValue(For.Name, out var entry) &&
                        entry.Errors.Count > 0;

        // Set the aria-invalid attribute based on the value and error
        if (hasValue)
        {
            output.Attributes.SetAttribute("aria-invalid", hasError ? "true" : "false");
        }
    }
}

The full code for this article is available on GitHub, feel free to explore it.

Razor Pages "default" Validation

As I mentioned earlier, In ASP.NET web applications, including Razor Pages, default validation is built upon validation attributes and the jquery.validate.unobtrusive library. This setup ensures seamless integration of client-side and server-side validation.

When data is submitted via a POST request, the validation process kicks in before the data reaches the OnPost handler. This means that any data entered by the user is checked against the specified validation rules defined by attributes such as [Required], [StringLength], [Range], and custom validation attributes.

Client-side

For example, if we define a typical input field with label and span for validation message:

<label asp-for="FirstName"></label>
<input asp-for="FirstName"/>
<span asp-validation-for="FirstName" class="text-danger"></span>

And we have property with attributes like that

[Required(ErrorMessage = "First Name is required")]
[StringLength(50, ErrorMessage = "First Name cannot be longer than 50 characters")]
[Display(Name = "First Name")]
public string FirstName { get; set; }

It would be translated into the next code:

<label for="FirstName">First Name</label>
<input type="text" 
    data-val="true" 
    data-val-length="First Name cannot be longer than 50 characters" 
    data-val-length-max="50" data-val-required="First Name is required" 
    id="FirstName" 
    maxlength="50" 
    name="FirstName" value="">
<span class="text-danger field-validation-valid" data-valmsg-for="FirstName" data-valmsg-replace="true"></span>

The rendered page transfers all model's data contract validations to HTML and utilizing data-* attributes to perform validation on change and submission. When something is wrong, an error message is injected into the span and aria-invalid attribute is set automatically.

Server-side Validation

By default, in Razor Pages, submitting a form triggers a POST request that results in a full page reload. In the case of POST in Razor Pages, we usually add an OnPost handler that might look like this:

 public IActionResult OnPost()
 {
    if (!ModelState.IsValid)
    {
        return Page();
    }

    // Store the user data in TempData
    TempData["User"] = JsonSerializer.Serialize(UserRegistration);
    return RedirectToPage("Success", UserRegistration);
 }

ModelState is a ModelStateDictionary. After the POST request is accepted but before the OnPost handler called, the framework would perform validation and fill it with validation results:

If there are no validation errors, ModelState.IsValidwill be true, and we will continue processing with a redirect to the success page.

But if we introduce any validation error

if (UserRegistration.PhoneNumber.Length < 4) {
    ModelState.AddModelError("User.PhoneNumber", "Phone format is wrong");   
}

We will have this information in the ModelState for the related property.

ModelState.IsValid will be false, and Razor Page will render the page with this error information put in place of the related tag helper.

Since we have a [BindProperty] attribute on the User model, returning the Page() will populate this model with data from the request. Therefore, all the data we input (except passwords) will be automatically filled back in.

<!-- template tag helper -->
<label asp-for="PhoneNumber"></label>
<input asp-for="PhoneNumber"/>
<span asp-validation-for="PhoneNumber" class="text-danger"></span>

<!-- rendered tag -->
<span class="text-danger field-validation-error" 
    data-valmsg-for="PhoneNumber" 
    data-valmsg-replace="true">Phone format is wrong</span>

This is how default Razor Pages validation works, and ASP.NET validation in general. If you want you could explore it in detail in official Microsoft documentation.

But how could we adapt this validation to use it with htmx?

Unobtrusive Validation with htmx

Htmx integrates with the HTML5 Validation API and will not issue a request for a form if a validatable input is invalid. However, since we do not use HTML5 when we use jquery.validate.unobtrusive we need to hook into the submit process to prevent submission if any client-side validation error is found.

Server-side we would need to render and return Partial - our server-side validated form instead of the whole page.

Client-side

First, let's set up the form as a separate partial _Form . We would require to get the user model and ViewData with some additional properties like handler route or trigger.

@model Xakpc.RazorHtmx.Validation.UserRegistrationForm

@{
    var handler = (string)ViewData["Handler"];
    var trigger = (ViewData["Trigger"] as string) ?? "submit";
}

<form method="post" hx-post="@handler" hx-swap="outerHtml" hx-trigger="@trigger">
    <partial name="_FormContent" model="@Model" />
</form>

We would use _Form partial like this

@page
@model Xakpc.RazorHtmx.Validation.Pages.UnobtrusiveHtmxModel
@{
    ViewData["Title"] = "User Registration";
    ViewData["Handler"] = Url.Page("UnobtrusiveHtmx");
    ViewData["Trigger"] = "valid-form";
}

...

<article>
    <partial name="_Form" model="Model.UserRegistration" view-data="ViewData" />
</article>
💡
@Url.Page("pageName") is a convenient way to get a URL to the page, but I usually use custom-made @Url.Handler("handlerName") for it.

Note ViewData["Trigger"] = "valid-form" that would be rendered as hx-trigger="valid-form". By default, AJAX requests are triggered by the “natural” event of an element. For a form, this is the submit event.

However, this won't work because the submit event is emitted even if there are validation errors. The solution here is to intercept the event and emit our own event if the form is valid.

$(document).ready(function () {
    $('form').on('submit', function (e) {
        var form = $(this);
        if (form.valid()) {
            // Form is valid, allow htmx to handle the submission
            htmx.trigger("form", "valid-form");
            e.preventDefault();                    
            return true;
            } else {
            e.preventDefault();
            return false;
        }
    });
});

When the form is valid the event valid-form would trigger hx-post and submit the form.

Server-side

Server-side validation works the same way in this case, but the way we produce the response changes. Razor Pages are smart enough to handle binding and validation even for AJAX/htmx requests (same thing).

public IActionResult OnPost()
{
    if (UserRegistration.PhoneNumber.Length < 4)
    {
        ModelState.AddModelError("PhoneNumber", "Phone format is wrong");
    }

    if (!ModelState.IsValid)
    {
        Response.StatusCode = 422;
        return Partial("_FormContent", UserRegistration);
    }

    // no need to use temp data to pass user object to another page
    return Partial("_SuccessUser", UserRegistration);
}

Note the return code 422 Unprocessable Entity - it is a smart way to tell the client that the form data has an error and cannot be processed. Unfortunately, htmx would not process requests with such a code - we need to set it up.

$(document).on('htmx:beforeSwap', function (event) {
    var evt = event.originalEvent; // Get the original event details
    if (evt.detail.xhr.status === 422) {
        // Allow 422 responses to swap as we are using this as a signal that
        // a form was submitted with bad data and want to rerender with the errors
        //
        // Set isError to false to avoid error logging in console
        evt.detail.shouldSwap = true;
        evt.detail.isError = false;
    }
});

Unobtrusive validation is great and very powerful, but it requires using jQuery. While this is absolutely fine, it does add some weight to our page: jquery-3.3.2.min.js + jquery.validate.min.js + jquery.validate.unobtrusive.min.js = 112 KB.

Could we omit that?


HTML5 Validation

Now let's remove jquery.validation and jQuery, and disable built-in validation with the setting ViewContext.ClientValidationEnabled = false. With this option disabled, Razor Pages will not convert Data Annotation attributes to HTML attributes.

💡
It's actually not necessary to disable ClientValidationEnabled. If you keep it enabled and just remove the jQuery libraries, you will still get all the attributes generated. You can then use them in your JavaScript validation however you like, including integrating them with HTML5 validation.

On the server side, not much will change because it does not rely on client validation.

Client-side

We would lose quite a lot of generated code. Remember the input snippet?

<label asp-for="FirstName"></label>
<input asp-for="FirstName"/>
<span asp-validation-for="FirstName" class="text-danger"></span>

Here is how it would look without client validation enabled - all data attributes and validation messages are gone.

<label for="FirstName">First Name</label>
<input type="text" id="FirstName" maxlength="50" name="FirstName" value="">
<span class="text-danger"></span>

Razor Pages has very limited support for HTML5 validation, and Microsoft has somewhat given up on improving it or replacing jQuery. From what I found, it only supports type and maxlength, not even required, which is a shame because supporting other built-in attributes could easily be done with tag helpers.

Here is an example of a tag helper that adds the required attribute to inputs, as well as a custom data-err-required attribute with an error message.


[HtmlTargetElement("input", Attributes = "asp-for")]
[HtmlTargetElement("textarea", Attributes = "asp-for")]
public class RequiredInputTagHelper : TagHelper
{
    [HtmlAttributeName("asp-for")]
    public ModelExpression For { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        var attribute = For.ModelExplorer.Metadata.ValidatorMetadata.FirstOrDefault(
            attr => attr is RequiredAttribute) as RequiredAttribute;

        if (attribute != null)
        {
            output.Attributes.SetAttribute("required", null);

            if (!string.IsNullOrEmpty(attribute.ErrorMessage))
            {
                output.Attributes.SetAttribute("data-err-required", attribute.ErrorMessage);    
            }
        }
    }
}

I collected some more tag helpers for my personal use.

As you can see, these tag helpers not only set up built-in HTML5 validation but also add a data-err-* attribute that can be used to customize the validation message.

To achieve this, we need to write some JavaScript and attach event listeners to handle the invalid event. Then, we can use setCustomValidity for custom validation messages.

document.addEventListener('DOMContentLoaded', function () {
    const inputs = document.querySelectorAll('input'); 

    inputs.forEach(function (input) {
        // Check if the input has any data-err-* attributes
        if (input.dataset.errRequired ||
            input.dataset.errLength ||
            input.dataset.errMinmax) {

            input.addEventListener('invalid', function (event) {
                const target = event.target;
                let customMessage = '';

                if (target.validity.valueMissing && target.dataset.errRequired) {
                    customMessage = target.dataset.errRequired;
                } else if ((target.validity.tooLong || target.validity.tooShort) && target.dataset.errLength) {
                    customMessage = target.dataset.errLength;
                } else if ((target.validity.rangeUnderflow || target.validity.rangeOverflow) && target.dataset.errMinmax) {
                    customMessage = target.dataset.errMinmax;
                }

                target.setCustomValidity(customMessage);
            });

            input.addEventListener('input', function (event) {
                event.target.setCustomValidity(''); // Clear custom message on input
            });
        }
    });
});

Example of HTML5 validation with a custom message

Server-side Validation

Server-side validation would work the same as in the default way in this case, no changes needed.

In the end, we did lose some client-side features, like the Password and Confirmation Password do not match validation, but this would be handled server-side luckily. The main benefit is that we got rid of heavy jQuery libraries, simplified our HTML, and set a foundation for htmx to take over.

If built-in validation is not enough (many such cases), you can use any validation library you wish. Some are more convenient for use with Razor Pages, while others are less so.

HTML5 Validation with htmx

For me, this is usually a preferred setup for validation. I like to keep it simple, so built-in client validation is enough for me in 90% of cases.

It is not slightly different from the HTML5 and htmx we explored before.

Client-side

We need to make some minor changes on the client side to make it work. Since htmx is integrated with the HTML5 Validation API and will not send a request for a form if a validatable input is invalid, we can remove the custom trigger hx-trigger="valid-form".

The default trigger for the form is submit so no hx-trigger attribute needed.

@page
@model Xakpc.RazorHtmx.Validation.Pages.Html5HtmxModel
@{
    ViewData["Title"] = "User Registration";
    ViewData["Handler"] = Url.Page("Html5Htmx");
    ViewContext.ClientValidationEnabled = false;    
}

<hgroup>
    <h2>User Registration - HTML5 with htmx</h2>
    <p>htmx form submit with <mark>HTML5</mark> validation</p>
</hgroup>

<article>
    <partial name="_Form" model="@Model.UserRegistration" view-data="ViewData"/>
</article>

We also would not need to prevent form submission anymore, but we would need to reattach events on the form after the swap is done (DOM is settled).

document.addEventListener('DOMContentLoaded', function () {
    setupValidation();

    document.body.addEventListener('htmx:afterSettle', function (evt) {
        if (evt.detail.xhr.status === 422) {
            setupValidation();
        }
    });
});

Server-side Validation

Server-side, nothing has changed. The server does not care about how we validate on the client side.

public class Html5HtmxModel : PageModel
{
    [BindProperty]
    public UserRegistrationForm UserRegistration { get; set; }

    public void OnGet()
    {
    }

    public IActionResult OnPost()
    {
        if (UserRegistration.PhoneNumber.Length < 4)
        {
            ModelState.AddModelError("PhoneNumber", "Phone format is wrong");
        }

        if (!ModelState.IsValid)
        {
            Response.StatusCode = 422;
            return Partial("_FormContent", UserRegistration);
        }

        // no need to use temp data to pass user object to another page
        return Partial("_SuccessUser", UserRegistration);
    }
}

As expected we have HTML5 validation with a custom message, submitted by htmx

Hypermedia-Friendly way

One of the somewhat controversial, but clever, ideas behind the hypermedia-friendly way to use htmx is inline scripting: writing your scripts directly within hypermedia, rather than placing them in an external file.

This can be done by using the hx-on attribute and incorporating functions or even inline code snippets to execute our JavaScript. For example, if we need to set the aria-invalid attribute based on the input's validity, instead of using input.addEventListener('input', function (event) {...}, we could use something like this:

<form hx-post="@handler" hx-swap="outerHtml"
      hx-on:focusout="event.target.setAttribute('aria-invalid', 
        event.target.validity.valid && event.target.value !== '' ? 'false' : 'true')">

      ...
</form>

This would set the attribute when we leave focus from any input on the form.

If we need to run a lot of code, we can put it into a function in the partial or, better yet, on the page where the partial is used.

<form hx-post="@handler" hx-swap="outerHtml"/>
     <label asp-for="FirstName"></label>
     <input asp-for="FirstName" hx-on:invalid="setCustomValidity(event.target)" />
     <span asp-validation-for="FirstName" class="text-danger"></span>

     <label asp-for="LastName"></label>
     <input asp-for="LastName" hx-on:invalid="setCustomValidity(event.target)" />
     <span asp-validation-for="LastName" class="text-danger"></span>

     <label asp-for="Email"></label>
     <input asp-for="Email" hx-on:invalid="setCustomValidity(event.target)" />
     <span asp-validation-for="Email" class="text-danger"></span>
     ...
</form>

<script>
    function setCustomValidity(target) {
        let customMessage = '';

        if (target.validity.valueMissing && target.dataset.errRequired) {
            customMessage = target.dataset.errRequired;
        } else if ((target.validity.tooLong || target.validity.tooShort) && target.dataset.errLength) {
            customMessage = target.dataset.errLength;
        } else if ((target.validity.rangeUnderflow || target.validity.rangeOverflow) && target.dataset.errMinmax) {
            customMessage = target.dataset.errMinmax;
        }

        target.setCustomValidity(customMessage);
    }
</script>

Finally, when everything is validated on both the client and server, we would see the success screen.

Conclusion

Integrating htmx with .NET Razor Pages for form validation offers a streamlined and efficient approach to both client-side and server-side validation. By leveraging built-in validation mechanisms, customizing them with tag helpers, and utilizing HTML5 validation APIs, we can create robust and user-friendly forms.

It still has some rough edges and missing parts, but nothing that can't be figured out with a few hours of work (or by using AI), and it provides a lot of room for exploring different ways to handle validation:

  • Built-in Razor Pages validation (is extremely powerful but somewhat outdated),

  • More modern variations like aspnet-client-validation,

  • HTML5 validation with vanilla JS,

  • or any third-party validation library.

You can choose whichever you prefer, and with minor code adjustments in tag helpers, you can achieve both client-side and server-side validation while fully supporting the DRY principle.

Feel free to explore the full code on GitHub to see these concepts in action and improve your form validation workflows, also feel free to subscribe to the series where I explore more htmx + dotnet crap, or ping me on X if you have any questions.