Form validation with Stimulus and server-side errors

Jordan Lee Jan 2026
2 tabs
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["field"]

  validateField(event) {
    const field = event.target
    const errorElement = field.parentElement.querySelector('.field-error')

    if (!field.checkValidity()) {
      field.classList.add('field-invalid')
      field.classList.remove('field-valid')

      if (errorElement) {
        errorElement.textContent = field.validationMessage
        errorElement.classList.remove('hidden')
      }
    } else {
      field.classList.remove('field-invalid')
      field.classList.add('field-valid')

      if (errorElement) {
        errorElement.classList.add('hidden')
      }
    }
  }

  validateForm(event) {
    let isValid = true

    this.fieldTargets.forEach(field => {
      if (!field.checkValidity()) {
        isValid = false
        field.dispatchEvent(new Event('blur'))
      }
    })

    if (!isValid) {
      event.preventDefault()

      // Focus first invalid field
      const firstInvalid = this.element.querySelector('.field-invalid')
      if (firstInvalid) firstInvalid.focus()
    }
  }
}
2 files · javascript, erb Explain with highlit

Client-side validation provides instant feedback, but server-side validation is the source of truth. I use Stimulus to add real-time validations (format, length, required fields) while still rendering server errors when validations fail on submit. The controller checks validity on blur or input events and shows inline error messages. When the form submits and the server returns errors, I render the form again with Turbo and error messages appear automatically. The progressive enhancement approach means forms work without JavaScript, but JavaScript makes them feel responsive. I also use the Constraint Validation API to tap into native browser validation and customize error messages.