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()
}
}
}
<%= form_with model: @post,
data: {
controller: "form-validation",
action: "submit->form-validation#validateForm"
} do |f| %>
<div class="form-group">
<%= f.label :title %>
<%= f.text_field :title,
required: true,
minlength: 5,
maxlength: 100,
class: "form-input",
data: {
form_validation_target: "field",
action: "blur->form-validation#validateField"
} %>
<% if @post.errors[:title].any? %>
<div class="field-error"><%= @post.errors[:title].first %></div>
<% else %>
<div class="field-error hidden"></div>
<% end %>
</div>
<div class="form-group">
<%= f.label :body %>
<%= f.text_area :body,
required: true,
minlength: 50,
rows: 8,
class: "form-input",
data: {
form_validation_target: "field",
action: "blur->form-validation#validateField"
} %>
<% if @post.errors[:body].any? %>
<div class="field-error"><%= @post.errors[:body].first %></div>
<% else %>
<div class="field-error hidden"></div>
<% end %>
</div>
<%= f.submit "Save", class: "btn btn-primary" %>
<% end %>
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.