Now that we’ve learned a minimal amount of JavaScript, let’s apply our skills to our Rails applications to make them slick.
These notes are a companion to the pg-ajax-1 project.
Let’s focus first on the experience of CRUDing comments. Right now if you add, update, or destroy a comment, the comments#create
, comments#update
, and comments#destroy
actions use the redirect_back
method to send you back to the page you were previously on; which is pretty cool, actually. Much better than redirect_to root_url
no matter where you were before.
But, you end up all the way at the top of the page; of course, since redirecting is the same as if you navigated to the page by typing in the address manually. This is very annoying if you had scrolled a long way down the feed, just wanted to leave a comment or delete a comment, and continue browsing.
Let’s improve this experience with Ajax. Here’s what we’ll have to do:
Change the default behavior of links and forms.
When the user clicks on a link/form, we’re going to keep them where they are instead of sending them to a different URL.
We’ll still make the same GET/PATCH/POST/DELETE request as before, to the same route and with the same parameters; but it will be in the background, using JavaScript.
.html.erb
template or redirecting as usual, we’ll respond with a .js.erb
template containing some jQuery.We’ll begin by improving the delete flow.
The first thing we need to do is make it so that when the user clicks on the delete link, it doesn’t actually do what <a>
elements are supposed to do, which is navigate them to the URL specified by the href=""
attribute. We want to keep them right where they are; in essence, we need to break the link.
Secondly, we need to attach a custom JavaScript event handler to the link such that when it is clicked, we will make the request to the route that the user would have navigated to using JavaScript. This will trigger the action so that the appropriate CRUD will still occur.
Finally, the request that is placed must be of the format .js
, rather than .html
. Then, we can respond_to
it accordingly.
Ok, phew. How are we going to do all this? Well, fortunately the link_to
and form_with
methods are going to do it all for us; but just as a thought experiment, here’s what we’d need in order to do it ourselves:
HTTP.get()
or HTTP.post()
to interact with APIs in Ruby; you provide it with a URL and it places a request to that URL.return false
from on("click")
’s callback, or use the preventDefault method, to prevent the user from going anywhere when they click the link.With these pieces, we could write the code ourselves; it would look something like the following:
<%= link_to "#", # comment, # removed the href since we're breaking the link anyway
# method: :delete, # removed the method since we're breaking the link anyway
class: "btn btn-link btn-sm text-muted",
id: "#comment_{comment.id}_delete_link" do # added an id to the link so that I can bind a click handler to it %>
<i class="fas fa-trash fa-fw"></i>
<% end %>
<script>
// bind the click handler
$("#comment_<%= comment.id %>_delete_link").on("click", function() {
$.ajax({
url: "/comments/<%= comment.id %>", // what URL to submit a request to
type: "DELETE", // make it a DELETE request
dataType: "script" // what is the format of the request
});
return false; // break the link
});
</script>
Fortunately, we don’t have to write the above code for every link we want to Ajaxify. Instead, we can add a very handy option on the link_to
helper method, which does the equivalent for us — remote: true
:
<%= link_to comment,
method: :delete,
class: "btn btn-link btn-sm text-muted",
remote: true do %>
<i class="fas fa-trash fa-fw"></i>
<% end %>
Add the remote: true
option, clear your server log, click the link, and observe the server log. You’ll notice that 1) the link didn’t seem to do anything when clicked, but 2) the request did in fact hit the correct route, 3) the request format was as JS
rather than as HTML
like usual, and 4) if you refresh the page, the comment was in fact deleted (since the action was triggered).
Great! Now all we have to do is update the HTML with jQuery to keep the client in sync with the database.
In the comments#destroy
action, let’s expand the respond_to
block to handle requests for format JS
:
respond_to do |format|
# Handle JSON and HTML formats above as usual
format.js do
render template: "comments/destroy.js.erb"
end
end
Now, as usual, let’s go create this template file in app/views/comments
:
// app/views/comments/destroy.js.erb
console.log("bye comment!")
Now try clicking delete on a comment. You ought to see bye comment!
in the JS console — your template is being rendered, but it’s a JavaScript template that is being executed by the browser.
A very important note: Since your templates are now JavaScript, one of your primary debugging tools — looking at HTML with View Source — can no longer help you. How do you see the actual JavaScript that your templates are producing and sending to the browser?
Chrome has your back. Go to the Network tab in the Dev Tools:
This will be crucial to use as we move along. You will definitely, 💯, absolutely make typos in the jQuery selectors that you try to compose using ERB tags in these templates, and it’s essential that you use the Network tab to debug.
Now, let’s use our jQuery skills to actually remove the comment from the DOM.
First, let’s make it easy on ourselves by putting a unique id=""
on it. Then it will be easy to select with $()
:
<!-- app/views/comments/_comment.html.erb -->
<li id="comment_<%= comment.id %>" class="list-group-item">
Which would produce something like:
<li id="comment_42" class="list-group-item">
Which would be easily selectable with $("#comment_42")
so that we can hide()
it or whatever.
comment_<%= comment.id %>
was not hard at all, but since we’re going to end up doing this a lot, Rails includes a helper method for it — dom_id
:
<li id="<%= dom_id(comment) %>" class="list-group-item">
Which does the exact same thing — essentially, dom_id(thing)
returns:
"#{thing.class.to_s.underscore}_#{thing.id}"
Now that it’s easy to select, let’s grab it with jQuery and remove it. If our goal is to, ultimately, execute some jQuery that looks like this:
$("#comment_42").remove();
But where is the 42
going to come from? Remember, since this is a .js.erb
template, we can 1) embed Ruby into it anywhere we please, and 2) we have access to any instance variables defined by the action; just like with .html.erb
templates!
// app/views/comments/destroy.js.erb
$("#<%= dom_id(@comment) %>").remove();
Give your Ajaxified delete link a whirl. Nice!
But it seems a little abrupt. Now that we have all of jQuery at our disposal, why not be a little smoother?
// app/views/comments/destroy.js.erb
$("#<%= dom_id(@comment) %>").fadeOut(5000, function() {
$(this).remove();
});
Okay, five seconds might be excessive, but you get the idea.
Congrats on Ajaxifying your first interaction!
Let’s improve the experience of adding a comment. It will follow the same pattern as deleting:
respond_to
block to handle requests for JS.Add the local: false
option to the form_with
helper that is rendering the form to add a comment:
<!-- app/views/comments/_form.html.erb -->
<%= form_with(model: comment, local: false) do |form| %>
Submit the form and verify that the form no longer navigates, the request is still made in the background, and the format of the request is now JS
instead of HTML
.
In the comments#create
action, let’s expand the respond_to
block to handle requests for format JS
:
respond_to do |format|
# Handle JSON and HTML formats above as usual
format.js do
render template: "comments/create.js.erb"
end
end
If you want to, you can be more concise here — since:
We can just say:
respond_to do |format|
# Handle JSON and HTML formats above as usual
format.js
end
With an empty block, or no block at all, and Rails will be able to find our template file.
Create the template file in app/views/comments
:
// app/views/comments/create.js.erb
console.log("howdy")
And make sure you wired everything up correctly. Once you’ve verified that howdy
appears in the console when you add a new comment, try printing the content of the new comment:
console.log("<%= @comment.body %>")
Once you’ve proven that you’re sending the data back, think about what interaction you’d like to use to actually update the client. Review my list of frequently used jQuery methods and see if any might come in handy.
At this point, it’s really up to you to use your JavaScript skills to craft a JavaScript response that fits your application’s context. But, let me show you a pattern that has served me well in many cases.
First, recall that we can pass a string containing HTML directly to the $()
method to create an element. How about if we do that with the comment’s body, perhaps within a <p>
for now, and then use the before()
jQuery method to insert the comment into the DOM just before the form for a new comment?
Let’s try it. First, we’ll need to add a way to select the <li>
which contains the <form>
, so that we can call before()
on it to insert a sibling element. Let’s try adding a dom_id
to it:
<!-- app/views/comments/_form.html.erb -->
<li id="<%= dom_id(comment) %>_form" class="list-group-item">
In the _form
partial for a new comment, comment
is a brand new, unsaved comment. So dom_id(comment)
doesn’t have an ID number to work with. So it returns new_comment
. Therefore, the above would produce:
<li id="new_comment_form" class="list-group-item">
You can reload and inspect to verify this output. Then, try something like this in your response:
var added_comment = $("<p><%= @comment.body %></p>");
$("#new_comment_form").before(added_comment);
This isn’t quite right, because there can be multiple new comment forms on the page, so the new comments will be appended to the wrong one (the first one). We need a more specific selector.
One very good option would be to add a more specific selector to the element itself:
<li id="<%= dom_id(comment.photo) %>_new_comment_form" class="list-group-item">
This would produce something like:
<li id="photo_42_new_comment_form" class="list-group-item">
This is specific enough for our needs. Now, we can update the JS template accordingly:
var added_comment = $("<p><%= @comment.body %></p>");
$("#<%= dom_id(@comment.photo) %>_new_comment_form").before(added_comment);
Another good option would be to learn to write more specific CSS selectors using the existing structure of the page; it might come in handy if you go deep into Ajax, as it did when web scraping.
Either way — we now have the comment being added to the correct spot in the DOM! 🎉
Right now we’re using a <p>
tag for the comment, but we had a beautifully styled component for a comment with a lot more markup, CSS classes, nested elements, etc, already. Fortunately, we don’t have to type all of the HTML for a comment right into the $()
method, because we’re 1) inside a .js.erb
template, and 2) we have a partial that represents a comment already!
var added_comment = $("<%= render 'comments/comment', comment: @comment %>");
If you try this and look at the response, you’ll see our partial being rendered beautifully:
Unfortunately, you’ll also see that it no longer works; if you try adding a comment, the DOM no longer updates, and you’ll see errors in the JS console.
The issue is that our HTML contains a lot of characters that are not allowed within a JavaScript string without first being escaped, just as there are many characters that aren’t allowed within a Ruby string without first being escaped — first and foremost, you can’t have "
within a double-quoted string without first escaping it with a \
.
Oh no! Does that mean we can’t use our partials after all, and we have to type in all the HTML ourselves, being careful to escape every illegal character? Thankfully, no — Rails includes a helper method called escape_javascript()
. Give it your string, and it will return another string with all characters that JavaScript doesn’t like nicely escaped:
var added_comment = $("<%= escape_javascript(render 'comments/comment', comment: @comment) %>");
Now give it a try and look at the response:
And, it works again — and it looks great.
If you want to, you can use an abbreviation for the escape_javascript()
helper — j()
:
var added_comment = $("<%= j(render 'comments/comment', comment: @comment) %>");
I sorta like how clearly escape_javascript()
reads, but the brevity of j()
is nice too. Your call.
Similarly, I like the explicitness of:
render 'comments/comment', comment: @comment
But we can also use the equivalent ultra-shortcut of:
render @comment
Using both abbreviations together gives us the ultra-concise:
var added_comment = $("<%= j(render @comment) %>");
Over time, after writing thousands of these JS response templates, I’ve come to prefer this abbreviated form.
One last detail — the previous comment stays in the form textarea, which is inconvenient. Let’s clear it out:
$("#<%= dom_id(@comment.photo) %>_new_comment_form #comment_body").val("");
The selectors I needed to do this happened to already be in the HTML due to how form_with
works, but I could have added them if I needed them. Ultimately it’s up to you to 1) add the selectors you need, 2) craft the JavaScript/jQuery you need to update your interface. There’s no formula to follow, and it will be unique to your context.
We should really probably stop here, but since it’s our first time Ajaxifying and we’re drunk with power, let’s have some fun. Maybe the comment should slide down instead of just appearing?
var added_comment = $("<%= j(render @comment) %>");
added_comment.hide();
$("#<%= dom_id(@comment.photo) %>_new_comment_form").before(added_comment);
added_comment.slideDown();
$("#<%= dom_id(@comment.photo) %>_new_comment_form #comment_body").val("");
Next, let’s improve the edit comment experience.
For each action, you should follow the standard Ajaxification steps:
remote: true
on link_to
or local: false
on form_with
.format.js
to the appropriate respond_to
block.id=""
attributes to the partials, if they don’t already have them.See if you can Ajaxify edit/update on your own.
When you’re ready to look at solutions for Ajaxifying CRUD for comments:
Explore the target to find other things to practice Ajax on:
If you can successfully Ajaxify the CRUD operations above, you’re in great shape to build snappy, modern web applications that meet the expectations of today’s users. The benefit of using this approach is that we’re still using most of our code — routes, controllers, models, even view templates (especially partials).
Even better, we’re still conforming to our mental model of RCAV+CRUD, which is straightforward for a small team, or even a single-person team, to iterate quickly with. This is a fantastic approach to use, especially in the early days while finding product/market fit, and satisfies the needs of 95% of the applications I’ve built for myself or for clients.
On the other hand: for very complicated “single page” applications that really don’t fit the RESTful, document/URL-based paradigm (think Google Sheets or Figma), this approach using Rails’ “Unobtrusive AJAX” may not be the best choice.
For cases like that, we will have to consider breaking apart our application into:
The JSON API that we develop will have to be much more robust than the taste we got earlier by simply doing:
format.json { render json: @movies }
Typically the back-end and front-end will now be developed by their own specialists, since each will now require more work and will require markedly different languages/paradigms.
We could build one of these web-clients using $().ajax
to fetch JSON from our API and $()
to create and insert elements into the DOM, but it’s usually better to use a framework like React, Vue, or Angular.
All in all, going the SPA-route dramatically increases cost and reduces development velocity versus using the Rails Ajax approach outlined above.1 But,in some cases, we have no choice. Only go down the SPA road when a simpler approach won’t work! Far too many teams choose an SPA framework when their app isn’t a single-page at all; if it’s a classic, document-based, RESTful CRUD application, you can build for 1/2 the cost if you treat as such. And, as you learned above, you can still make it snappy and interactive using sprinkles of unobtrusive Ajax.
But if and when we do decide to go down this road, the nice part is that the same robust JSON API that we develop can feed native iOS and Android clients. ↩