Exploring Form Validation With Go and HTMX

Forms are a foundational piece of many modern websites; they provide a way for users to provide input to a service for a variety of purposes such as authentication, search and data input. Form validation is therefore an important topic when creating forms online. When a user inputs any data to a server, the data should always be validated by the server before acting on the data. Validation may contain specific rules such as the data not being empty, or checking that an email address is valid. In this article we’ll explore one way to implement form input validation within a Go application utilizing HTMX.

HTMX provides a way for a single input element to send its value to the server when its value changes. We will utilize this ability to send an input’s value to the server to perform validations on the value and respond with an error message in case the value is not valid. The sent error message will be shown below the form input in question.

In order to make this work, we need to create a form with some inputs and a handler serving as the endpoint for validating user input. To get started, let’s create a template containing a form:

templates/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://unpkg.com/htmx.org@2.0.1"></script>
    <title>Form Validation</title>
</head>
<body>
    <form>
        <div
            hx-target="#name_errors"
            style="display: flex; flex-direction: column;"
        >
            Name
            <input
                type="text"
                name="name"
                hx-post="/validate?validate=notempty&validate=has"
            />
            <span id="name_errors"></span>
        </div>
        <div
            hx-target="#email_errors"
            style="display: flex; flex-direction: column;"
        >
            Email
            <input
                type="text"
                name="email"
                hx-post="/validate?validate=email"
            />
            <span id="email_errors"></span>
        </div>
        <div
            hx-target="#password_errors"
            style="display: flex; flex-direction: column;"
        >
            Password
            <input
                type="password"
                name="password"
                hx-post="/validate?validate=notempty&validate=hasupper&validate=hasspecial"
            />
            <span id="password_errors"></span>
        </div>
        <input type="submit" value="Submit">
    </form>
</body>
</html>

Here we define a simple form with 3 inputs, name, email and password. All of the inputs contain a hx-post attribute that points each input to the validation endpoint /validate, with each call including specific query parameters to define how each field should be validated. Each query parameter named validate is a string that will instruct the endpoint to call a specific validation method on the value posted in the request. If any validation fails, error messages will be placed within the <span> element below the input that made the request.

Then we need to create our application entrypoint, the main function that includes handlers for rendering our single index template, and an endpoint for validating inputs. Let’s start by getting the package name and imports out of the way:

main.go

package main

import (
	"html/template"
	"log"
	"net/http"
	"regexp"
	"strings"
	"unicode"

	"github.com/go-chi/chi/v5"
)

Then, let’s introduce the system we’ll be using to run validations on a post request:

main.go

var emailRegexp = regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`)
var stringValidations = map[string]func(value string) string {
	"notempty": func(value string) string {
		value = strings.TrimSpace(value)
		if value == "" {
			return "must not be empty"
		}
		return ""
	},
	"email": func(value string) string {
		value = strings.TrimSpace(value)
		if !emailRegexp.Match([]byte(value)) {
			return "must be valid"
		}
		return ""
	},
	"hasupper": func(value string) string {
		for _, r := range value {
			if unicode.IsUpper(r) {
				return ""
			}
		}
		return "must contain an uppercase character"
	},
	"hasspecial": func(value string) string {
		if !strings.ContainsAny(value, `!-_,.@/()=+?`) {
			return "must contain one of !-_,.@/()=+?"
		}
		return ""
	},
}

Here we define a map of type map[string]func(value string) string; a map where strings (names of validation types) are mapped to functions to test a string value. For example, the notempty function tests that the given value is not empty (containing only white space). Our HTML template places one or more of the validation function names into the query parameters of the hx-post attribute’s URL. Then let’s define our main function to put it all together:

main.go

func main() {
	r := chi.NewRouter()
	r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		t := template.Must(template.ParseFiles("templates/index.html"))
		if err := t.Execute(w, nil); err != nil {
			log.Println(err)
		}
	})

	r.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		validations := q["validate"]

		// form data will only contain a single value since the request is coming
		// from a single input element
		r.ParseForm()
		var value string
		for _, v := range r.PostForm {
			value = v[0]
		}

		errors := make([]string, 0, len(validations))
		for _, validation := range validations {
			if validationError := stringValidations[validation](value); validationError != "" {
				errors = append(errors, validationError)
			}
		}

		if len(errors) > 0 {
			w.Header().Set("hx-reswap", "innerHTML")
			w.WriteHeader(http.StatusOK)
			w.Write([]byte(strings.Join(errors, ", ")))
		}
	})

	http.ListenAndServe(":8080", r)
}

Here, we define 2 handlers; one to serve our template containing the form, and one for validating inputs. The validation handler retrieves validation names from query parameters, then parses the PostForm and retrieves its first (and only) value. Then we range over each validation found in query parameters and append errors into a slice of strings. In case any errors are appended, we will “render”, i.e. return a string containing all of the errors to be shown below the input.

Note that this validation is only to improve user experience, and any endpoint receiving form submissions should still run proper input data validation before acting on it.

Sign up or log in to start commenting