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).

⚠️ Warning! ⚠️ this article contains JavaScript and CoffeeScript

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 the action attribute of the form and the data-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. Using button_to instead of link_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 native disabled 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 like json, you'll need to add JavaScript event listener for those ajax:* 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.