Asynchronous JavaScript and XML is an approach to build web applications that are able to make quick, incremental updates to the user interface without reloading the entire browser page. This makes the application faster and more responsive to user actions.
When you type http://localhost:3000
into your browser's address bar
and hit 'Go', it makes a request to the server, parse the response and
fetches all associated assets like JavaScript files, stylesheets and
images. Then it assembles the page together. However, using Ajax we
can update the parts of the page without needing to get the full page
data from the server and fetching all associated assets.
In particular, using JavaScript to make requests and incrementally update the website improves the user experience and reduce friction for certain actions.
We will be using JavaScript and AJAX to improve the user experience of BlogSpot, our blogging website by implementing features like autocomplete, form validation and more.
Throughout the session, I will assume some familiarity with JavaScript and jQuery. In case you would like to learn more about Javascript, you can refer to The Modern JavaScript Tutorial.
Since Rails 6, Rails uses Webpacker as the default build system for JavaScript, CSS and other assets - which can often be tricky and unintuitive.
As JavaScript can now be written in two places: app/javascripts/packs
and js.erb
view files - it's important to understand the distinction
between two.
app/javascripts/packs
should contain JavaScript that is same for all
users (say form-validation) whereas js.erb
view files should contain
JavaScript specific to a user.
One important Gotcha is that libraries included in app/javascript/packs
are not accessible in js.erb
view files unless they are exposed to the
global object. For example, you might want jQuery both in your
app/javascript/packs
and js.erb
view files, you will need to
'expose' it using expose-loader
plugin.
If you’re using Webpacker with Rails 6, you may have run into an issue trying to use jQuery in JavaScript from a global context. Usually this will show up in the console as something like “$ is not defined,” particularly if you’re trying to use jQuery from your *.js.erb views for SJR (Server-generated JavaScript Responses) for AJAX.
Before we begin, let's take a look at how I structured the application's
layout (app/views/layouts/application.html.erb
):
<!DOCTYPE html>
<html>
<head>
<title>Blogspot</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<%= render partial: 'navbar' %>
<div class="container pt-2" id="main-content">
<%= yield %>
</div>
<% if flash.any? %>
<script>
<% if flash[:alert] %>
toastr.error('<%= flash[:alert] %>');
<% elsif flash[:notice] %>
toastr.success('<%= flash[:notice] %>');
<% end %>
</script>
<% end %>
</body>
</html>
Note that I have assigned id
to the container - we are going to change
contents of the container later using Javascript and AJAX as follows:
$("#main-content").html("<%= j render partial: 'index' %>");
The above line renders index.html.erb
and replaces the inner content
of #main-content
element with it.
We are now going to display articles from the dashboard using AJAX.
The app/views/articles/index.html.erb
file contains just:
<%= render partial: 'index' %>
While app/views/articles/_index.html.erb
contains the actual
implementation of view:
<% width_mapping = {1 => '25', 2 => '50', 3 => '75', 4 => '100'} %>
<% @articles.in_groups_of(4, false) do |article_group| %>
<% wrapper_width = "w-#{width_mapping[article_group.length]}" %>
<div class="card-deck mb-2 <%= wrapper_width %>">
<% article_group.each do |article| %>
<div class="card">
<% if article.cover_photo.attached? %>
<%= image_tag article.cover_photo, class: 'img-responsive card-img-top', size: '200x200' %>
<% end %>
<div class="card-body">
<h5 class="card-title"><%= article.title %></h5>
<h6 class="card-subtitle mb-2 text-muted"><%= article.tags %></h6>
<p class="card-text">
<%= article.content[0, 200] %>
<%= '...' if article.content.size > 200 %>
</p>
<%= link_to 'Read more', article, class: 'btn btn-primary' %>
</div>
<div class="card-footer text-muted">
Uploaded by <u><%= article.author.name %></u> <%= time_ago_in_words(article.created_at) %> ago.
</div>
</div>
<% end %>
</div>
<% end %>
Likewise, the app/views/articles/show.html.erb
also renders the
partial app/views/articles/_show.html.erb
which contains actual
implementation.
Since we want to display the article using AJAX, we will modify the link to:
<%= link_to 'Read more', article, class: 'btn btn-primary', remote: true, data: {disable_with: 'Loading...'} %>
The option remote: true
makes the GET request using Ajax rather than
normal submit mechanism while data: {disable_with: 'Loading...'}
replaces the text of button and gives the user some indication that
button has been clicked.
If we check the server's log, we will find an interesting line:
Processing by ArticlesController#show as JS
The remote: true
option worked and the GET request is made using
JavaScript and not HTML as it usually is.
However, the Rails application does not know how to respond to JS
requests yet. We create a new file app/views/articles/show.js.erb
as
follows:
$("#main-content").html("<%= j render partial: 'show', locals: {article: @article} %>");
Before:
After:
To submit forms using AJAX, we make similar changes.
- Edit
app/views/articles/_form.html.erb
in particular:
<%= bootstrap_form_with(model: article, layout: :horizontal, remote: true) do |form| %>
...
<div class="text-center">
<%= form.submit nil, class: 'btn btn-primary', data: {disable_with: 'Submitting...'} %>
</div>
<% end %>
Similar to remote: true
, local: false
instructs the form to be
submitted using AJAX and data: {disable_with: 'Submitting...'}
replaces the text on submit button after the button is clicked to give
some indication that form is submitted.
- Respond to AJAX requests in
create
action ofArticlesController
:
def create
@article = Article.new(article_params)
@article.author = current_user
respond_to do |format|
if @article.save
...
format.js
else
...
format.js
end
end
end
- Add the following to
app/views/articles/create.js.erb
:
<% if @article.persisted? %>
$("#main-content").html("<%= j render partial: 'show', locals: {article: @article} %>
toastr.success('Article was successfully created.')
<% else %>
$("#main-content").html("<%= j render partial: 'new', locals: {article: @article} %>");
toastr.error('Unable to create article')
<% end %>
persisted?
returns whether the record has been saved and we are
updating the content on the page through JavaScript as well as setting
toast notifications.
Before:
After:
Often, there are form validations are that only possible through the backend. For example: making sure that no user has registered with the same e-mail address before.
While we can wait until the users submit the form and provide feedback (and make users fill out the form again, and again - until they get it right), using AJAX to provide immediate feedback can improve user experience.
- Add a new action
validate_email
toUsersController
. This action will be used to validate if there is an account already with email. Make sure to add appropriate route and ability as well.
class UsersController < ApplicationController
...
def validate_email
if User.exists?(email: params[:email])
render json: {message: 'repeated'}.to_json
else
render json: {message: 'unique'}.to_json
end
end
end
- Modify the
app/views/users/_form.html.erb
file as follows:
<%= bootstrap_form_with(model: user) do |form %>
...
<div class="form-group row">
<%= form.label :email, class: 'col-form-label col-sm-2 required' %>
<div class="col-sm-10">
<%= form.email_field :email, id: 'email-field', required: true, wrapper: false %>
<div class="valid-feedback">Looks good!</div>
<div class="invalid-feedback">
E-mail has already been taken. Please try a different e-mail.
</div>
</div>
</div>
...
<% end %>
<script>
var url = new URL('<%= validate_email_users_url %>');
var field = $("#email-field");
field.change(async function(){
// If the field is not filled or has an invalid e-mail address
if (!field.is(':valid'))
return;
params = {email: field.val()};
url.search = new URLSearchParams(params).toString();
let response = await fetch(url);
if (response.ok) {
let json = await response.json();
if (json.message == 'unique')
field.removeClass('is-invalid').addClass('is-valid');
else
field.removeClass('is-valid').addClass('is-invalid');
} else
alert("HTTP-Error: " + response.status);
});
</script>
Since bootstrap_form
does not generate valid-feedback
and invalid-feedback
divs, we have
modified the mark up for email field manually and added the id
email-field
.
Then we attach an event to #email-field
, which triggers the function
whenever user "stops typing".
If the field does not pass HTML validations (field is not field or has invalid e-mail address), we don't bother with verifying the email is unique.
When the field passes HTML validations, we make a GET request to server
to the action validate_email
(which we defined earlier). Based on the
response, we set the classes for the e-mail field.
- Fetch | Modern JavaScript
- Validation | Bootstrap v4.5
- change() | jQuery API Documentation
- CSS Pseudo-classes
Another feature that can impact the user experience tremendously is autocomplete. Let's help people find articles by entering some characters of the article's title.
We are going to use EasyAutocomplete, a jQuery autocomplete plugin but the general steps will be same for any other plugin too. I don't have much to add to this excellent tutorial by GoRails: Global Autocomplete Search (Example) | GoRails.
As the application data grows, we frequently need pagination - displaying only first 10 or 50 results. Combining pagination with AJAX can be pretty powerful, as we can load the first page of results in the initial request and update the records as user demands more pages.
Rails has some gems for pagination - the most popular of which Kaminari. It's dead simple to use, even with AJAX, requiring only:
<%= paginate @articles, remote: true %>
to render a fully functional, AJAX-aware pagination bar. Be sure to make
use of pre-built themes by using rails g kaminari:views bootstrap4
(or
the appropriate frontend library).
The simplest way to get new information from the server is through periodic polling. For example, you want to refresh the list of messages in a chat application periodically without user refreshing the page. The process works as follows: Every time 10 seconds, make an AJAX request to the server and update the page. This is called "periodic polling".
A much better way to get new information periodically is "long polling":
- A request is sent to the server
- The server does not close the connection until it has a message to send.
- When a message is ready, the server responds to request with it.
- Client makes a new request immediately.
Thus, when the server replies only when it has a message to deliver.
However, you should always consider using Server Side Events and WebSocket which provide even better performance and near real-time responses if you can.
- Long Polling
- WebSockets
- Action Cable Overview: the Rails implementation of WebSockets