DEV Community

Cover image for Form validation in Rails
Abeid Ahmed
Abeid Ahmed

Posted on

Form validation in Rails

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

  1. Turbo
  2. Stimulus.js

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.


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 }
Enter fullscreen mode Exit fullscreen mode

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 =

  def create
    user =

      # do something
      render json: ErrorSerializer.serialize(user.errors), status: :unprocessable_entity


  def user_params
    params.require(:user).permit(:name, :email, :password)
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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] = { |key, val| render_errors(errors: val, type: key) }.flatten

    def render_errors(errors:, type:) do |msg|
        normalized_type = type.to_s.humanize
        msg = "#{normalized_type} #{msg}"
        { type: type, detail: msg }
Enter fullscreen mode Exit fullscreen mode

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| %>
    <%= f.label :name %>
    <%= f.text_field :name %>
    <p data-input-validation-target="errorContainer" data-error-type="name" role="alert"></p>

    <%= f.label :email %>
    <%= f.email_field :email %>
    <p data-input-validation-target="errorContainer" data-error-type="email" role="alert"></p>

    <%= f.label :password %>
    <%= f.password_field :password %>
    <p data-input-validation-target="errorContainer" data-error-type="password" role="alert"></p>

  <%= f.submit "Sign up" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

vulcanobr profile image
Marcelo Severo • Edited

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) ?

ryanb1303 profile image
Ryan B

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

 respond_to do |format|
        format.html { redirect_to users_path, success: "Success " }
        format.json { render :show, status: :ok, location: @gender }
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: user.errors, status: :unprocessable_entity }
Enter fullscreen mode Exit fullscreen mode
ukrmaxim profile image

Good job. Keep it up. Thanks.