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.

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
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 = 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
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] = 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
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| %>
  <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 %>
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.

Discussion (1)

Collapse
vulcanobr profile image
Marcelo Severo • Edited on

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