Asynchronous HTTP requests in Rails

🗓 - ⏱ 10 minute read

Today is a special day. It’s the day I’ll (mostly) talk about Javascript!

I’ve been struggling with AJAX requests in Rails apps for a while. But I’ve started using them a lot recently, and pieces of the puzzle kinda fell together. Asynchronous requests can be handy when you need to update some parts of your application’s page without reloading the whole thing. I’ll show you how to do this with plain ol’ vanilla Javascript (and its fetch() method), and Rails 6 native server-side partial rendering.

[2020-05-10 Update: If you’re more confortable following along a video, I’ve recorded a screencast.]

Some context

Alright. Let’s say you have a list of essays from your app’s blog. They’re sorted from newest to oldest. But you’d like your readers to have the ability to filter them by topic.

You’d start by adding some buttons at the top of your feed. Now, when readers click the button Ruby, you’d like your feed to only display essays with the category Ruby. All of this, without reloading the whole page.

Something like this 👇

a gif showing someone clicking on three topic buttons and filtering post on the fly

This sounds like a job for some asynchronous HTTP requests.

The Rails bit

Let’s dive right in and add a new route:

  # Expose an endpoint for our request
  resources :posts, only: :index, controller: 'blog/posts'

This will be the endpoint our requests ping. Now, here’s a basic controller:

  # Located at: app/controllers/blog/posts_controller.rb

  class Blog::PostsController < ApplicationController
    def index
      @posts = Post.all
    end
  end

And the corresponding view:

  <!-- Located at: app/views/blog/posts/index.html.erb -->

  <h1>My app's blog</h1>

  <!-- Filters -->
  <section>
    <p>Check posts by topic: </p>
    <button id="ruby-filter">Ruby</button>
    <button id="javascript-filter">Javascript</button>
    <button id="remote-filter">Remote working</button>
  </section>

  <!-- Posts' index -->
  <section id="posts-list">
    <%= render 'posts_list', posts: @posts %>
  </section>

Note that:

Speaking of partial, here’s posts_list.html.erb:

  <!-- Located at: app/views/blog/posts/\_posts_list.html.erb -->

  <% posts.each do \|post\| %>
    <%= link_to blog_post_path(post.url) %>
      <h2><%= post.title %></h2>
    <% end %>
    <%= post.excerpt %>
  <% end %>

We’re looping over a collection of posts. For each post, we generate a clickable title and an excerpt.

And now, ladies, gentlemen, and variations thereupon 1, let’s make some Javascript!

Building asynchronous requests in Rails with fetch(), step-by-step

Before we begin, you can either write your Javascript in your app/assets/javascripts directory or in your app/javascript/packs directory based on your Rails configuration (i.e. Do you have webpacker installed or not?). Both will work just fine!

The basic syntax for fetch()

Here are the outlines of our Javascript file:

  // Located at app/javascript/packs/blog_filters.js

  // Store the DOM element embedding the list
  const postsList = document.getElementById('posts-list');

  // Wrap our fetch() method in a function we can call whenever we want
  const filterPosts = () => {
    // Store our controller enpoint for clarity
    let actionUrl = 'posts';

    fetch(actionUrl, {
      // We'll add some configuration here.
    }).then((response) => {
      // And we'll do some fancy stuff there.
    });
  }

  // Store all filter-type elements
  const rubyFilter = document.getElementById('ruby-filter');
  const javascriptFilter = document.getElementById('javascript-filter');
  const remoteFilter = document.getElementById('remote-filter');

  // Trigger filterPosts when users click on a filter button
  rubyFilter.onclick = () => { filterPosts('ruby'); }
  javascriptFilter.onclick = () => { filterPosts('javascript'); }
  remoteFilter.onclick = () => { filterPosts('remote'); }

Here’s what blog_filters.js does:

Now, let’s work some magic between our Javascript file and our controller.

Fleshing out fetch()

fetch() takes an URL as the first parameter. We’ve already done that. Now, we’ll add details to the second parameter (it’s called the init object).

  // Located at app/javascript/packs/blog_filters.js

  const postsList = document.getElementById('posts-list');

  const filterPosts = () => {
    let actionUrl = 'posts';

    fetch(actionUrl, {
      method: 'GET',
      headers: {
        'X-CSRF-Token':     document.getElementsByName('csrf-token')[0].getAttribute('content'),
        'X-Requested-With': 'XMLHttpRequest',
        'Content-Type':     'application/html',
        'Accept':           'application/html'
      },
      credentials: 'same-origin'
    }).then((response) => {
      // And do stuff there.
    });
  }

  const rubyFilter = document.getElementById('ruby-filter');
  const javascriptFilter = document.getElementById('javascript-filter');
  const remoteFilter = document.getElementById('remote-filter');

  rubyFilter.onclick = () => { filterPosts('ruby'); }
  javascriptFilter.onclick = () => { filterPosts('javascript'); }
  remoteFilter.onclick = () => { filterPosts('remote'); }

What’s happening here?

This bit will ping our Blog::PostsController#index (through the route stored in actionUrl). Remember, we want our controller to filter our essays based on the category sent through our fetch() method. So we need to add that category to our request:

  // Located at app/javascript/packs/blog_filters.js

  const postsList = document.getElementById('posts-list');

  const filterPosts = (category) => {
    let categoryParams = `?category=${category}`;
    let actionUrl = 'posts' + categoryParams;

    fetch(actionUrl, {
      method: 'GET',
      headers: {
        'X-CSRF-Token':     document.getElementsByName('csrf-token')[0].getAttribute('content'),
        'X-Requested-With': 'XMLHttpRequest',
        'Content-Type':     'application/html',
        'Accept':           'application/html'
      },
      credentials: 'same-origin'
    }).then((response) => {
      // And do stuff there.
    });
  }

  const rubyFilter = document.getElementById('ruby-filter');
  const javascriptFilter = document.getElementById('javascript-filter');
  const remoteFilter = document.getElementById('remote-filter');

  rubyFilter.onclick = () => { filterPosts('ruby'); }
  javascriptFilter.onclick = () => { filterPosts('javascript'); }
  remoteFilter.onclick = () => { filterPosts('remote'); }

I’ve added a parameter category to my filterPosts function. Then, I’m building my some Rails params with category and, I’m adding them to my actionUrl. Now, I can access my category in my Blog::PostsController through my ActionController::Parameters. 👌

Filtering data in our controller

I can update our controller based on the presence of the category key in my params:

  # Located at: app/controllers/blog/posts_controller.rb

  class Blog::PostsController < ApplicationController
    def index
      if params['category'].present?
        @posts = Post.where(category: params['category'])
        render partial: 'posts_list', locals: { posts: @posts}, layout: false
      else
        @posts = Post.all
      end
    end
  end

Let’s break it down:

If your partial is somewhere else (like in your shared directory, just give your controller the relative path - i.e. ../../shared/posts_list). Javascript kinda places itself in between my controller and my view. Why? Because we’ll only update one bit of the page by manipulating the DOM.

layout: false tells Rails not to look for a template (since I’m feeding my Javascript method with a partial).

Handling the response from our controller

The first thing I like to do, is to check the HTTP status sent from my controller. If it’s a 200, I’ll use the response to replace the posts list in my view.

  // Located at app/javascript/packs/blog_filters.js

  const postsList = document.getElementById('posts-list');

  const filterPosts = (category) => {
    let categoryParams = `?category=${category}`;
    let actionUrl = 'posts' + categoryParams;

    fetch(actionUrl, {
      method: 'GET',
      headers: {
        'X-CSRF-Token':     document.getElementsByName('csrf-token')[0].getAttribute('content'),
        'X-Requested-With': 'XMLHttpRequest',
        'Content-Type':     'application/html',
        'Accept':           'application/html'
      },
      credentials: 'same-origin'
    }).then((response) => {
      if (response.status == 200) {
        response.text().then((content) => {
          postsList.innerHTML = content;
        })
      }
    });
  }

  const rubyFilter = document.getElementById('ruby-filter');
  const javascriptFilter = document.getElementById('javascript-filter');
  const remoteFilter = document.getElementById('remote-filter');

  rubyFilter.onclick = () => { filterPosts('ruby'); }
  javascriptFilter.onclick = () => { filterPosts('javascript'); }
  remoteFilter.onclick = () => { filterPosts('remote'); }

What did I do? I applied the text() method to my controller response. response is - and I quote the Mozilla documentation - a readable stream of byte data (you gotta love Javascript 🙃). text() takes this stream and turns it into a UTF-8-encoded string.

In our case, here’s what happens:

If the response status is an error (like a 500), I can show the user an error. Whatever’s tickling your fancy.

Phew! We just changed the posts section of my page without reloading the page. No fancy framework. No HTML written inside the Javascript code. Just some well-integrated server-side rendering and vanilla Javascript. 👌

Screencast

That’s is for today folks! I hope you enjoyed it as much as I did.

If anything seems odd, ping me on Twitter or create an issue on GitHub.

Cheers,

Rémi - @mercier_remi

  1. 👋 Doctor Who fans.