Submitting Ruby on Rails Forms via Ajax

21 July 2020

Ever wanted to submit a form and manipulate HTML page elements without triggering a full-page reload in Rails?

This tutorial demonstrates a simple way to submit and process form data via a data-remote form. By the end of this post, you will understand how to: (1) submit Rails forms asynchronously, (2) validate and handle asynchronous data, and (3) use Server-generated JavaScript Responses (SJR) to respond to form submissions.

I’ve tried to make this post accessible to beginner to intermediate level Rails developers. This workflow is a fundamental skill that belongs in any Rails developer's toolbox. In addition to form data, this same workflow is insanely useful in a variety of other contexts—including links, API calls, webhook interactions.

A repo with source code for this tutorial is available here.

Table of Contents - Ajax for Ruby on Rails Forms

  1. Overview
  2. Create a new Rails app
  3. Generate a scaffold
  4. Validate the model
  5. Set up form and display results
  6. Submit form via Ajax
  7. Process successful entries
  8. Closer look at Ajax efficiency and granular control
  9. Render validation errors
  10. Bug / gut check

A. Overview

What we'll build:

Just a simple form with a single text input that saves entries to a database.

There’s an important detail about this form—the data-remote attribute:

This attribute means the form will submit results via Ajax. In turn, Rails responds to successful form entries using JavaScript contained in a .js.erb file. Rails’ asset pipeline parses this .js.erb file—first the embedded Ruby (.erb) part, then the JavaScript (.js) part.

Practically speaking, this means you can include Ruby code inside JavaScript code. We’ll use standard JavaScript in our .js.erb file, but you could just as easily use jQuery or other JS libraries, plugins, etc.

We will validate inputs for presence (name cannot be empty) and uniqueness (name cannot already exist in the database). These validations will happen on the server side—at the model level—as they normally would in a Rails app.

app/models/car.rb

This walk-through ignores other common validations, notably front end validations, to stay focused on the relevant material.

If the form validation fails, Rails will reject the entry and display an error message.

If the form passes validation, we’ll use JavaScript and Ruby via SJR to add the new entry to the list without re-serving the entire page.

The result is an elegant alternative to Rails full-page reloads.

There are other options to build similar features, including Stimulus, JavaScript interception, jQuery Ajax, and other more robust front end solutions (React, Vue, Ember, etc.). But, in many use cases, this is a perfect compromise between simplicity and functionality.

B. Create a new Rails app

(1)
Run rails -v
$ rails -v
Rails 6.0.X

This walk-through assumes you are using Rails 6. It should work on all versions >= 5.1.0 (release date 27 Apr 2017), when Rails replaced jQuery with its own unobtrusive javascript adapter called rails-ujs. If you're using an earlier version of Rails, visit https://www.learnetto.com/blog/rails-ajax to read more about the switch from jQuery to rails-ujs.

(2)
Create a new Rails app with rails new sjr_basics --database=postgresql
$ rails new sjr_basics --database=postgresql
create
create README.md
create Rakefile
create .ruby-version
create config.ru
create .gitignore
create Gemfile
[...]
├─ webpack-dev-server@3.11.0
└─ ws@6.2.1
✨ Done in 14.09s.
Webpacker successfully installed 🎉 🍰
(3)
Comment out gem 'jbuilder' in Gemfile
Gemfile

Disabling jbuilder prevents Rails from generating API-related code in our scaffolding. We are disabling this functionality for simplicity.

(4)
Run bundle
$ bundle
Using rake 13.0.1
Using concurrent-ruby 1.1.6
Using i18n 1.8.2
Using minitest 5.14.0
[...]
Using webpacker 4.2.2
Bundle complete! 21 Gemfile dependencies, 85 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

C. Generate a scaffold

(5)
Generate a scaffold called car with a single attribute: name
$ rails g scaffold car name:string
Running via Spring preloader in process 7200
invoke active_record
create db/migrate/20200715004137_create_cars.rb
create app/models/car.rb
invoke test_unit
create test/models/car_test.rb
create test/fixtures/cars.yml
invoke resource_route
route resources :cars
invoke scaffold_controller
create app/controllers/cars_controller.rb
invoke erb
create app/views/cars
create app/views/cars/index.html.erb
create app/views/cars/edit.html.erb
create app/views/cars/show.html.erb
create app/views/cars/new.html.erb
create app/views/cars/_form.html.erb
invoke test_unit
create test/controllers/cars_controller_test.rb
create test/system/cars_test.rb
invoke helper
create app/helpers/cars_helper.rb
invoke test_unit
invoke assets
invoke scss
create app/assets/stylesheets/cars.scss
invoke scss
create app/assets/stylesheets/scaffolds.scss
(6)
Migrate the db with rails db:migrate
$ rails db:migrate
== 20200715004137 CreateCars: migrating =======================================
-- create_table(:cars)
-> 0.0479s
== 20200715004137 CreateCars: migrated (0.0480s) ==============================
(7)
Start a server with rails s, navigate to http://localhost:3000/cars, and confirm you see something like this

D. Validate the model

(8)
Validate the presence and uniqueness on the name attribute in app/models/car.rb
app/models/car.rb
(9)
Open a Rails console with rails c and confirm the model validations work
2.6.1 :008 > car_1 = Car.create(name: 'Tesla')
(0.2ms) BEGIN
Car Exists? (0.4ms) SELECT 1 AS one FROM "cars" WHERE "cars"."name" = $1 LIMIT $2 [["name", "Tesla"], ["LIMIT", 1]]
Car Create (0.3ms) INSERT INTO "cars" ("name", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [["name", "Tesla"], ["created_at", "2020-07-15 00:46:13.534695"], ["updated_at", "2020-07-15 00:46:13.534695"]]
(0.4ms) COMMIT
=> #
2.6.1 :009 >
2.6.1 :010 >
2.6.1 :011 > car_2 = Car.create(name: 'Tesla')
(0.2ms) BEGIN
Car Exists? (0.4ms) SELECT 1 AS one FROM "cars" WHERE "cars"."name" = $1 LIMIT $2 [["name", "Tesla"], ["LIMIT", 1]]
(0.3ms) ROLLBACK
=> #
2.6.1 :012 > car_2.errors
=> #, @messages={:name=>["has already been taken"]}, @details={:name=>[{:error=>:taken, :value=>"Tesla"}]}>
2.6.1 :013 >

This shows us adding two cars, car_1 and car_2. The car_2 gets rejected because the name is not unique.

E. Set up form and display results

(10)
Add @car to the index method in controller
app/controllers/cars_controller.rb

We need to add this object so we can pass it to the form_with helper in the next step.

(11)
Display the form on app/views/cars/index.html.erb
app/views/cars/index.html.erb
(12)
Now when you refresh the page, you'll see the form. And results will populate in the table below. Here is what it should look like after you add a couple cars

F. Submit form via Ajax

(13)
Remove local: true from form. Also confirm that you're using form_with
app/views/cars/_form.html.erb

This is a small but important change. We're telling Rails to submit the form using Ajax instead of a normal POST request. Rails will detect this in the controller and we’ll use it to trigger a JavaScript-specific response.

(14)
When you refresh the page and inspect the form HTML, you'll see it now has a data-remote: true attribute

G. Process successful entries

(15)
Add the following code to app/controllers/cars_controller.rb to handle results using respond_to blocks in the create method
app/controllers/cars_controller.rb

This code instructs Rails to render different response formats depending on whether @car saves successfully. If @car saves, we render a JavaScript response. If it fails, we render an HTML response.

(16)
Extract car to a partial and use that partial to display the list of cars
app/views/cars/_car.html.erb
app/views/cars/index.html.erb
(17)
Create app/views/cars/create.js.erb and add the following code
app/views/cars/create.js.erb

Rails uses this code to respond to successful entries. Since we specified format.js in our controller, Rails knows to look in the app/views/car directory for the create.js.erb file. For more on respond_to, see https://kolosek.com/rails-respond_to-block/.

This JavaScript adds a row to the list of cars, clears the input field, and, finally, focuses the cursor in the input field so the user can create another car without having to click on the box again.

(18)
Now, you can create a new car and it should show up in the list right away

H. Closer look at Ajax's efficiency and granular control

Let's pause here and look at some server logs. The switch to Ajax forms is subtle but significant. You should understand (A) what exactly is happening on a server level, and (B) why it is valuable.

First, look at the following server log entry for a traditional Rails form submission. In development you can see these entries in the terminal window where you started a server with rails s.

Here we've submitted a sample object called Dinosaur, also with a single name attribute). The log shows what happened, which I'll explain below so you can follow along:

Started POST "/dinosaurs" for ::1 at 2020-07-14 15:09:23 -0700
Processing by DinosaursController#create as HTML
Parameters: {"authenticity_token"=>"mVBW1zCbt5fmXeI2UEmfPsunI153ktZnBeyqyNzzrTl7eVnANXCZjavJw9Hb0vhRPYIkPTvEFWUZYyCpZr9XIw==", "dinosaur"=>{"name"=>"Test"}, "commit"=>"Create Dinosaur"}
(0.4ms) BEGIN
↳ app/controllers/dinosaurs_controller.rb:28:in `create'
Dinosaur Exists? (1.4ms) SELECT 1 AS one FROM "dinosaurs" WHERE "dinosaurs"."name" = $1 LIMIT $2 [["name", "Test"], ["LIMIT", 1]]
↳ app/controllers/dinosaurs_controller.rb:28:in `create'
Dinosaur Create (1.0ms) INSERT INTO "dinosaurs" ("name", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [["name", "Test"], ["created_at", "2020-07-14 22:09:23.408398"], ["updated_at", "2020-07-14 22:09:23.408398"]]
↳ app/controllers/dinosaurs_controller.rb:28:in `create'
(3.2ms) COMMIT
↳ app/controllers/dinosaurs_controller.rb:28:in `create'
Redirected to http://localhost:3000/dinosaurs
Completed 302 Found in 18ms (ActiveRecord: 6.0ms | Allocations: 3886)

Started GET "/dinosaurs" for ::1 at 2020-07-14 15:09:23 -0700
Processing by DinosaursController#index as HTML
Rendering dinosaurs/index.html.erb within layouts/application
Rendered dinosaurs/_form.html.erb (Duration: 1.8ms | Allocations: 522)
Dinosaur Load (0.5ms) SELECT "dinosaurs".* FROM "dinosaurs"
↳ app/views/dinosaurs/index.html.erb:16
Rendered dinosaurs/index.html.erb within layouts/application (Duration: 4.8ms | Allocations: 1370)
[Webpacker] Everything's up-to-date. Nothing to do
Completed 200 OK in 17ms (Views: 14.5ms | ActiveRecord: 0.5ms | Allocations: 5418)

This shows the server processing our POST request as HTML. The data parameters are passed as a hash. Our model validation triggers a search to ensure the name is unique. Then it inserts and commits the object into the database. Finally, it redirects to the dinosaurs index path, triggering a full reload of the index page (that's the GET request/response in the second log entry).

Compare that with the server log entry for our Car Ajax submission:

Started POST "/cars" for ::1 at 2020-07-14 15:15:56 -0700
Processing by CarsController#create as JS
Parameters: {"authenticity_token"=>"2IuTHT+KI9SAVCK2qoTUGDvqTiD2NiGkgmNSFZYfq/w6opwKOmENzs3AA1EhH7N3zc9JQ7pg4qae7Nh0LFNR5g==", "car"=>{"name"=>"Test"}, "commit"=>"Create Car"}
(0.2ms) BEGIN
↳ app/controllers/Cars_controller.rb:29:in `block in create'
Car Exists? (0.7ms) SELECT 1 AS one FROM "cars" WHERE "cars"."name" = $1 LIMIT $2 [["name", "Test"], ["LIMIT", 1]]
↳ app/controllers/Cars_controller.rb:29:in `block in create'
Car Create (0.6ms) INSERT INTO "cars" ("name", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [["name", "Test"], ["created_at", "2020-07-14 22:15:56.554028"], ["updated_at", "2020-07-14 22:15:56.554028"]]
↳ app/controllers/Cars_controller.rb:29:in `block in create'
(0.5ms) COMMIT
↳ app/controllers/Cars_controller.rb:29:in `block in create'
Rendering cars/create.js.erb
Rendered cars/_car.html.erb (Duration: 0.3ms | Allocations: 123)
Rendered cars/create.js.erb (Duration: 0.7ms | Allocations: 235)
Completed 200 OK in 10ms (Views: 1.3ms | ActiveRecord: 2.0ms | Allocations: 4320)

Notice how this approach doesn’t require a separate GET request to fetch the whole page? Instead, Rails just renders create.js.erb. This significantly reduces server response time, resulting in a faster user experience and lower server costs.

I. Render validation errors

At this point, our form works fine for successful form submissions. But what happens when our form fails validation?

(19)
Try adding a car with a name that already exists in the database

As you can see, it looks like nothing happened. The car does not appear in the list and there is no other feedback provided to the user. Tesla remains in the input field. This is not acceptable.

What happened? Begin by looking at the server log:

Started POST "/cars" for ::1 at 2020-07-15 10:33:11 -0700
Processing by CarsController#create as JS
Parameters: {"authenticity_token"=>"MMyJ/XU7viLguoOxJbd0C+rIUORPFm2gSfqIJ8HQA0yFo1RHD/RTI+bD3YE2bBFSIVgd0qMuEW5I0/J3URuJmg==", "car"=>{"name"=>"Tesla"}, "commit"=>"Create Car"}
(0.3ms) BEGIN
↳ app/controllers/Cars_controller.rb:29:in `block in create'
Car Exists? (0.4ms) SELECT 1 AS one FROM "cars" WHERE "cars"."name" = $1 LIMIT $2 [["name", "Tesla"], ["LIMIT", 1]]
↳ app/controllers/Cars_controller.rb:29:in `block in create'
(0.2ms) ROLLBACK
↳ app/controllers/Cars_controller.rb:29:in `block in create'
Rendering cars/index.html.erb within layouts/application
Rendered cars/_form.html.erb (Duration: 1.4ms | Allocations: 674)
Car Load (0.3ms) SELECT "cars".* FROM "cars"
↳ app/views/cars/index.html.erb:16
Rendered collection of cars/_car.html.erb [3 times] (Duration: 0.7ms | Allocations: 358)
Rendered cars/index.html.erb within layouts/application (Duration: 27.3ms | Allocations: 1934)
[Webpacker] Everything's up-to-date. Nothing to do
Completed 200 OK in 45ms (Views: 36.0ms | ActiveRecord: 1.2ms | Allocations: 9336)

The browser submitted a POST request via Ajax. CarsController#create attempted to process the request, which failed the model validation and got rolled back. Consistent with our instructions in the controller, Rails rendered index.html.erb.

But index.html.erb doesn't pick up the errors. Why not?

Answer: Turbolinks. The Turbolinks code works by swapping HTML elements. It doesn't know exactly what to do with our asynchronous JS data. If you want to do a deeper dive on Turbolinks, which I strongly recommend, https://thoughtbot.com/upcase/videos/turbolinks is a good starting point. As you would expect, the Turbolinks repo (https://github.com/turbolinks/turbolinks) is also very well-documented and accessible.

To solve this problem, we will use the turbolinks_render gem, which helps pass the information Rails needs to respond to the Ajax request. You should read more about this gem from its author, Jorge Manrubia. For our purposes, it is sufficient to understand that turbolinks_render allows us to render validation errors when a data-remote form fails validation.

(20)
Add turbolinks_render to Gemfile, run bundle, and restart the server
Gemfile
(21)
Now when you try submitting a duplicate car, Rails will serve up an error message

Again, it is helpful to look at the server log here to see what's happening:

Started POST "/cars" for ::1 at 2020-07-14 15:56:32 -0700
Processing by CarsController#create as JS
Parameters: {"authenticity_token"=>"2IuTHT+KI9SAVCK2qoTUGDvqTiD2NiGkgmNSFZYfq/w6opwKOmENzs3AA1EhH7N3zc9JQ7pg4qae7Nh0LFNR5g==", "car"=>{"name"=>"Test"}, "commit"=>"Create Car"}
(0.2ms) BEGIN
↳ app/controllers/Cars_controller.rb:29:in `block in create'
Car Exists? (1.0ms) SELECT 1 AS one FROM "cars" WHERE "cars"."name" = $1 LIMIT $2 [["name", "Test"], ["LIMIT", 1]]
↳ app/controllers/Cars_controller.rb:29:in `block in create'
(0.2ms) ROLLBACK
↳ app/controllers/Cars_controller.rb:29:in `block in create'
Rendering cars/index.html.erb within layouts/application
Rendered cars/_form.html.erb (Duration: 2.0ms | Allocations: 675)
Car Load (0.4ms) SELECT "cars".* FROM "cars"
↳ app/views/cars/index.html.erb:16
Rendered collection of cars/_car.html.erb [1 times] (Duration: 0.3ms | Allocations: 122)
Rendered cars/index.html.erb within layouts/application (Duration: 4.8ms | Allocations: 1735)
[Webpacker] Everything's up-to-date. Nothing to do
Completed 200 OK in 20ms (Views: 12.4ms | ActiveRecord: 1.8ms | Allocations: 9161)

After determining the name already exists in the database, Rails renders the index view. Specifically, turbolinks_render converts the JS data into a format that Turbolinks can render.

Now our code correctly handles entries whether they pass or fail validations.

J. Bug / gut check

If you take a close look at this code, you will notice a bug. Our form retains its error messages when an entry fails validation and the user fixes and resubmits an entry:

We can easily fix this with some additional JavaScript. But that’s not why I'm highlighting this bug and we will not fix it here.

The lesson: Especially as a beginner, it can be enticing to add Ajax features to Rails apps. It can feel like you're providing a more focused user experience. However, this bug shows the level of attention, detail, and testing (a.k.a. costs) that accompanies these kinds of features.

Some developers cite this apparent complexity as one of Rails' weaknesses. In some cases, that assessment is correct.

In many other cases (the vast majority, by my estimation), the Rails approach serves a useful purpose here. The majestic monolith forces you to acknowledge complexity creep. It’s a gut check. A code smell. Do you really need such granular control? Is this actually making your product simpler for users? The answer is often "no." You can add it if you need to, but it’s not the default for a good reason. This is a great example of convention over configuration in action.

The End

That’s all for this tutorial. Using this same approach, you can add remote: true to elements like links, buttons, events, etc. to trigger SJR responses.

Thank you to Jorge Manrubia (currently an engineer at Basecamp) for creating and documenting turbolink_render, which makes this process much easier than it would otherwise be. It appears the upcoming Turbolinks 6 release will render turbolinks_render unnecessary. However, I expect Jorge's work will remain relevant for pre-Turbolinks 6 Rails projects.
Thanks to Compound Writing members who reviewed this post: Josh Lelon Mitchell and Eugene Yan. 🤓