I've seen and implemented many ways to validate a form. Be it replacing a portion of the form with a Turbo stream or by using the more traditional approach of looping through the errors and then displaying the validation messages from the erb
template itself. Although these techniques do the job, I feel that it violates common user experiences. I'll not be diving into the violations because this isn't the scope of this article. Instead, I'll be sharing my approach on how I validate forms and render server errors if any.
Tools used
Outlining the steps
When we submit a form in Rails, Turbo fires numerous events, such as turbo:submit-start
, turbo:submit-end
, and so on (Full list of events can be seen here). What we're interested in is the turbo:submit-end
event that Turbo fires after the form submission-initiated network request is complete.
We'll tap into the event and look for any server-side errors. If there are errors, then we'll display them with the help of Stimulus.js without refreshing the page and without replacing the entire form.
Approach
Assume that we are validating a user sign-up form. We'll first set up our User
model, then UsersController
, then we'll move onto the form
, and then lastly we'll write some Stimulus.js code.
# app/models/user.rb
class User < ApplicationRecord
validates :name, presence: true
validates :email, presence: true, uniqueness: true
validates :password, presence: true, length: { minimum: 6 }
end
I know this isn't ideal for validating a user
, but for the sake of this tutorial, it should work.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
user = User.new(user_params)
if user.save
# do something
else
render json: ErrorSerializer.serialize(user.errors), status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password)
end
end
I'd like you all to focus on the else
block of the create
action. We want to send a JSON
response of all the errors that are triggered in a format that can be easily consumed by our Stimulus controller.
errors = [
{
type: "email",
detail: "Email can't be blank"
},
{
type: "password",
details: "Password is too short (mininum is 6 characters)"
}
]
This is the format of the errors
that we want. Let's define the ErrorSerializer
module to render this format.
# app/serializers/error_serializer.rb
module ErrorSerializer
class << self
def serialize(errors)
return if errors.nil?
json = {}
json[:errors] = errors.to_hash.map { |key, val| render_errors(errors: val, type: key) }.flatten
json
end
def render_errors(errors:, type:)
errors.map do |msg|
normalized_type = type.to_s.humanize
msg = "#{normalized_type} #{msg}"
{ type: type, detail: msg }
end
end
end
end
Now that we've covered the backend portion, let's move onto implementing the form and the Stimulus.js controller.
<%# app/views/users/new.html.erb %>
<%= form_with model: @user, data: { controller: "input-validation", action: "turbo:submit-end->input-validation#validate" } do |f| %>
<div>
<%= f.label :name %>
<%= f.text_field :name %>
<p data-input-validation-target="errorContainer" data-error-type="name" role="alert"></p>
</div>
<div>
<%= f.label :email %>
<%= f.email_field :email %>
<p data-input-validation-target="errorContainer" data-error-type="email" role="alert"></p>
</div>
<div>
<%= f.label :password %>
<%= f.password_field :password %>
<p data-input-validation-target="errorContainer" data-error-type="password" role="alert"></p>
</div>
<%= f.submit "Sign up" %>
<% end %>
Notice the p
tag. We've added the data-error-type
by which we'll know where to show the errors for a particular field. Next, we'll write some javascript to insert the errors in the respective p
tags.
// app/javascript/controllers/input_validation_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ['errorContainer']
async validate(event) {
const formData = await event.detail.formSubmission
const { success, fetchResponse } = formData.result
if (success) return
const res = await fetchResponse.responseText
const { errors } = JSON.parse(res)
this.errorContainerTargets.forEach((errorContainer) => {
const errorType = errorContainer.dataset.errorType
const errorMsg = extractError({ errors, type: errorType })
errorContainer.innerText = errorMsg || ''
})
}
}
function extractError({ errors, type }) {
if (!errors || !Array.isArray(errors)) return
const foundError = errors.find(
(error) => error.type.toLowerCase() === type.toLowerCase()
)
return foundError?.detail
}
When we submit the form, turbo:submit-end
event gets fired calling in the validate
function in the Stimulus.js controller. If the validation succeeds then we do an early return
, else we destructure the errors and render them inside the p
tag that we defined earlier in our sign-up form.
This is one of the many ways to render errors on the client-side. If you have a better implementation, please let us know in the comments. Also, if you'd like to read a particular topic, do let me know.
Top comments (3)
Good afternoon, congratulations for the post, I'm a beginner in Rails, I reproduced your example but I'm not able to generate the rails messages in the view; json render appears; but when I remove the json nothing happens; what do I need to change in your example? do I need to install the stimulus ( rails webpacker:install: stimulus) ?
if the json you remove is in the users_controller that you remove, you need to add new error renderer. like this example generated from rails g scaffold
Good job. Keep it up. Thanks.