Form validation in Rails
Tags: rails, beginners, tutorial, ui
Published: 2021-08-14 08:44:02

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](https://turbo.hotwired.dev/handbook/introduction)
2. [Stimulus.js](https://stimulus.hotwired.dev/)

## 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](https://turbo.hotwired.dev/reference/events)). 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.

```ruby
# 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.

```ruby
# 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.

```json
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.

```ruby
# 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.

```erb
<%# 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.

```js
// 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.