Behind The Scenes: Rails UJS
Rails UJS (Unobtrusive JavaScript) is the JavaScript library that helps Rails do its magic when we use options like remote: true
for many of the html helpers.
In this article I’ll try to explain the main concept of how this works to make it transparent for the user. Knowing a bit about the inner workings can help when debugging issues and also if we need to do something more complex than the provided interactions but reusing what’s provided.
If you are using an old version of Rails and you are still using jquery-ujs , some code will not reflect how it does the magic, but most of these concepts apply as well (rails-ujs
is a re-implementation of jquery-ujs
removing the jquery dependency).
The Rails helpers
When we want to have a link, a button or a form do an AJAX request instead of a standard request, we use, for example: remote: true
. We have other options like disable_with: "...loading"
, confirm: "Are you sure?"
and method: :post
, the helper methods won’t do anything related to JavaScript but will just add a data-
attribute .
Rails UJS will read those data attributes during specific events to trigger the enhanced behavior.
link_to "Example", "#", remote: true
=> "<a href='#' data-remote>Example</a>"
button_tag 'Example2', disable_with: "Are you sure?"
=> "<button data-disable-with='Are you sure?'>Example2</button>"
Adding Rails UJS
The library comes installed by default for any new Rails application, both with Sprockets and Webpacker, but if you are using an older Rails version or moving from Sprockets to Webpacker you’ll need to adjust some code.
With Sprockets
If you are still using the old Assets Pipeline to handle your JavaScript, make sure you have this line in your assets/javascript/application.js
file:
//= require rails-ujs
The library is included as a part of Rails core, so that’s all you need.
With Webpacker
Since Webpacker works differently, you can’t use the same code that comes with the rails
gem, you need to add the official node package .
First you need to add it to your package.json
with yarn add @rails/ujs
. Then, in your javascripts/packs/application.js
file, you need to require the module:
// app/javascript/packs/application.js
require("@rails/ujs").start();
Initialization
In the previous code snippet we can see we have to call the start
function, and it will add many event listeners for each feature and type of element that it supports. Here are some of the event as an example:
# re-enables a button tag after the ajax form was submitted
delegate document, Rails.buttonDisableSelector, 'ajax:complete', enableElement
# disables a link tag when clicked
delegate document, Rails.linkClickSelector, 'click', handleDisabledElement
# handles a link href attribute as remote request
delegate document, Rails.linkClickSelector, 'click', handleRemote
# show a confirm dialog when an input tag changes
delegate document, Rails.inputChangeSelector, 'change', handleDisabledElement
It also makes sure we have a CSRF token when we need it:
document.addEventListener('DOMContentLoaded', refreshCSRFTokens)
The complete method is here
Notice this is CoffeeScript code, not JavaScript
Delegation of Events
In the previous section you can see the start
function is calling a delegate
function with the document object, some selector, an event type and the fourth parameter is the function to execute to respond to that event. This method is also defined by Rails UJS and it takes care of responding to events for elements added even after the start
function was called.
Rails.delegate = (element, selector, eventType, handler) ->
element.addEventListener eventType, (e) ->
target = e.target
target = target.parentNode until not (target instanceof Element) or matches(target, selector)
if target instanceof Element and handler.call(target, e) == false
e.preventDefault()
e.stopPropagation()
Instead of adding the event to each element, Rails adds an event listener to the document
object and then it checks if the actual target of the event matches the given selector. This way, no matter when an element is added, there’s no need to add event listener to it, Rails takes care of that.
You can see in the 5th line that it calls handler.call(target, e)
, to execute the function with the actual element that produced the event. Another interesting part is that it will prevent the default event behavior and stop the propagation of the event down the tree if the handler function returns false
.
The Features
Rails UJS provides 4 features: remote (ajax) requests, confirmation dialogs, request method (for elements that don’t natively support a method), and disabling elements when a parent form is being submitted. Each feature is contained in one file here .
Confirm
This is the simplest of all the features. When an element with the data-confirm
attribute is clicked, it will first show a Confirm dialog and, depending on the answer, it will call stopEverything
that takes care of stopping any extra action.
# extracts some functions from the Rails object
{ fire, stopEverything } = Rails
# this is the handler function that was passed to `delegate`
Rails.handleConfirm = (e) ->
stopEverything(e) unless allowAction(this)
# we can override the `confirm` method of the Rails object to provide custom `confirm` dialogs!
Rails.confirm = (message, element) ->
confirm(message)
# this shows the confirmation and also fires a `confirm:complete` event to do something
# after a confirm dialog is accepted/rejected
allowAction = (element) ->
message = element.getAttribute('data-confirm')
return true unless message
answer = false
if fire(element, 'confirm')
try answer = Rails.confirm(message, element)
callback = fire(element, 'confirm:complete', [answer])
answer and callback
We already found an advanced feature, we can override Rails.confirm to use the provided event handling to show a custom made confirmation dialog
Method
This feature allows you, for example, to execute a POST or DELETE request when a user clicks an A
tag. Links are only allowed to do GET requests, there’s no native HTML attribute to change that, but we can use this handy helper for common cases like a Delete
link that would require a DELETE request while still using an A
tag.
What this handler does is a bit hacky. Since A
tags can’t do requests other than GET, Rails UJS does this:
- creates a Form element in memory using the link’s
href
attribute as theaction
attribute of the form and thedata-method
value as a hidden field with the name_method
- sets the CSRF token as a hidden input field inside the form
- adds a submit button
- adds a
display: none
style to the form as an inline style - appends the form to the DOM
- uses JavaScript to click the
submit
input of the form
# this is the handler passed to `delegate`
Rails.handleMethod = (e) ->
link = this
# gets the method provided
method = link.getAttribute('data-method')
return unless method
href = Rails.href(link)
csrfToken = Rails.csrfToken()
csrfParam = Rails.csrfParam()
# creates the form
form = document.createElement('form')
# sets the method
formContent = "<input name='_method' value='#{method}' type='hidden' />"
if csrfParam? and csrfToken? and not Rails.isCrossDomain(href)
# adds the CSRF token
formContent += "<input name='#{csrfParam}' value='#{csrfToken}' type='hidden' />"
# adds the submit input
formContent += '<input type="submit" />'
form.method = 'post'
# adds the href as the action
form.action = href
form.target = link.target
form.innerHTML = formContent
# adds display none
form.style.display = 'none'
# appends form
document.body.appendChild(form)
# clicks the submit button
form.querySelector('[type="submit"]').click()
It uses a form with a POST method and the special _method
param with the provided value. Rails will use this in the backend to convert the POST request into a DELETE request for example. This is done this way because the Form element’s method
attribute does not support methods other than GET and POST (docs )
Personally, I would recommend NOT using this helper. You can get a similar effect using the
button_to
helper provided by Rails that already creates a form element wrapping the button with the desired method and action. Usingbutton_to
instead oflink_to
gives you the advantage of this working even if JavaScript is disabled!
Disable/Disable With
This feature is a bit more complex because it involves 2 events: the start of a request (to disable the element) and the end of the request (to re-enable the element). And it also uses different implementation to prevent the clicks depending on the element:
- when using
disable_with
in a button Rails UJS will add the nativedisabled
attribute - when using it in a form, the submit input will be disabled
- when using it in a link, Rails UJS will add an event listener to prevent the click
When disabling an element, Rails UJS will also add a data attribute for each disabled element with the original text and then replace the text with the provided one, so then it can revert the change.
Finally, when the request is done, an ajax:complete
event will be fired and Rails UJS uses that to re-enable all the elements doing the inverse process (replacing the original text, removing disable
attributes and removing event listeners).
# handle passed to `delegate` to enable an element
Rails.enableElement = (e) ->
if e instanceof Event
return if isXhrRedirect(e)
element = e.target
else
element = e
# checks the type of element to call the different actions
if matches(element, Rails.linkDisableSelector)
enableLinkElement(element)
else if matches(element, Rails.buttonDisableSelector) or matches(element, Rails.formEnableSelector)
enableFormElement(element)
else if matches(element, Rails.formSubmitSelector)
enableFormElements(element)
# Let's see one of the disable functions as an example
disableLinkElement = (element) ->
return if getData(element, 'ujs:disabled')
# gets the provided text when disabled
replacement = element.getAttribute('data-disable-with')
if replacement?
# stores the original content
setData(element, 'ujs:enable-with', element.innerHTML)
# replace content with the new one
element.innerHTML = replacement
# adds an event listener to stopEverything because this is a link tag
element.addEventListener('click', stopEverything)
setData(element, 'ujs:disabled', true)
enableLinkElement = (element) ->
# gets the stored original content
originalText = getData(element, 'ujs:enable-with')
if originalText?
# sets the original content
element.innerHTML = originalText
setData(element, 'ujs:enable-with', null)
# removes the event listener
element.removeEventListener('click', stopEverything)
setData(element, 'ujs:disabled', null)
Remote / AJAX
This is, probably, the most used Rails UJS feature. It’s really clean and a great example of progressive enhancement: you have a simple initial code that works, then JavaScript comes in and adds more functionality into it.
When submitting a remote form or clicking a remote link, Rails UJS will intercept the action to do an AJAX request using a cross-browser compatible helper. Depending on the type of element that triggers the event it will use different logic to extract the URL and any other param required (for a form it will also serialize the inputs*, and will read a data-params
attribute if we need to provide more params for when using a link tag). It will also take the provided data-method
to build the AJAX request.
# handler passed to `delegate`
Rails.handleRemote = (e) ->
element = this
return true unless isRemote(element)
# we can listen to the `ajax:before` event to prevent this ajax request!
unless fire(element, 'ajax:before')
fire(element, 'ajax:stopped')
return false
# we can provide more information for the ajax request, like using credentials, or the type
# the response we are expecting
withCredentials = element.getAttribute('data-with-credentials')
dataType = element.getAttribute('data-type') or 'script'
if matches(element, Rails.formSubmitSelector)
# if it's a form, use the content to generate the data
button = getData(element, 'ujs:submit-button')
method = getData(element, 'ujs:submit-button-formmethod') or element.method
url = getData(element, 'ujs:submit-button-formaction') or element.getAttribute('action') or location.href
# strip query string if it's a GET request
url = url.replace(/\?.*$/, '') if method.toUpperCase() is 'GET'
if element.enctype is 'multipart/form-data'
data = new FormData(element)
data.append(button.name, button.value) if button?
else
data = serializeElement(element, button)
setData(element, 'ujs:submit-button', null)
setData(element, 'ujs:submit-button-formmethod', null)
setData(element, 'ujs:submit-button-formaction', null)
else if matches(element, Rails.buttonClickSelector) or matches(element, Rails.inputChangeSelector)
# if it's a button or an input element, it needs a `data-url` attribute!
method = element.getAttribute('data-method')
url = element.getAttribute('data-url')
data = serializeElement(element, element.getAttribute('data-params'))
else
# this is the case for a link, it will use the `href attribute`
method = element.getAttribute('data-method')
url = Rails.href(element)
data = element.getAttribute('data-params')
# then it calls the `ajax` function (defined by Rails UJS) to execute and process the
# request, and to trigger some events that we can listen to to react to the whole lifecycle
ajax(
type: method or 'GET'
url: url
data: data
dataType: dataType
...
...
Let’s dig a bit more into that ajax
function, because it also has some kind of hacky tricks. The source is here .
First we find a list of values that we can use for the data-type
attribute. This way, we can use remote: true, type: :json
to actually respond with a JSON view instead of a JS view (we would need some JavaScript function attached to ajax:success
or ajax:complete
events to process the JSON object)
AcceptHeaders =
'*': '*/*'
text: 'text/plain'
html: 'text/html'
xml: 'application/xml, text/xml'
json: 'application/json, text/javascript'
script: 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript'
Now let’s check the actual ajax
function. The first line prepared the options for a different format, then it calls another function called createXHR that accepts the options and a callback function when the XHR request is done:
Rails.ajax = (options) ->
options = prepareOptions(options)
# this is CoffeeScript syntax to define an anonymous function
xhr = createXHR options, ->
# when the request is done, it calls processResponse with the response content
response = processResponse(xhr.response ? xhr.responseText, xhr.getResponseHeader('Content-Type'))
# then it executes some callbacks that fire different events
if xhr.status // 100 == 2
# fires `ajax:success`
options.success?(response, xhr.statusText, xhr)
else
# fires `ajax:error`
options.error?(response, xhr.statusText, xhr)
# fires `ajax:complete`
options.complete?(xhr, xhr.statusText)
if options.beforeSend? && !options.beforeSend(xhr, options)
return false
if xhr.readyState is XMLHttpRequest.OPENED
xhr.send(options.data)
If you use a
data-type
likejson
, you’ll need to add JavaScript event listener for thoseajax:*
events.
The final part of the code is the processResponse
function. It takes care of parsing the response content and, if the data-type
is script
it executes it. To execute a script
content, Rails UJS actually creates a script
tag, appends the script tag to the document’s head (so the browser executes the code), and then removes the element (to clean things up).
processResponse = (response, type) ->
if typeof response is 'string' and typeof type is 'string'
if type.match(/\bjson\b/)
# if it's json, parse it and return it
try response = JSON.parse(response)
else if type.match(/\b(?:java|ecma)script\b/)
# if it's a script, attach it to the DOM
script = document.createElement('script')
script.setAttribute('nonce', cspNonce())
script.text = response
document.head.appendChild(script).parentNode.removeChild(script)
else if type.match(/\b(xml|html|svg)\b/)
# if it's an `xml` like content, parse it as a JavaScript element
parser = new DOMParser()
type = type.replace(/;.+/, '') # remove something like ';charset=utf-8'
try response = parser.parseFromString(response, type)
# return the json or JavaScript object, returns nothing if type is script
response
Bonus Tip
If you ever need to submit a form using JavaScript and you want to maintain the Rails UJS features, you can use the Rails.fire
function like: Rails.fire(someFormObject, "submit")
. If you simply do someFormObject.submit()
, it won’t fire all the required events in the DOM tree and Rails UJS won’t be able to handle it.
Conclusion
There is a lot more to dig into for this library, but we covered the main idea behind the 4 main features of it. Checking the code we can find many data attributes that allows us to customize each feature and also many events that are fired during the lifecycle of each of them, allowing us to build more complex behavior on top of the default actions. We usually use this to return JS responses, but we found we can also use it for JSON and XML/HTML objects, writing only the code to handle this object and not the whole request process.