DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Building optimistic UI in Rails powered by Turbo

This article was originally published on Rails Designer blog


A while back I showed you how to build optimistic UI using custom elements. It worked great! And you thought too, it was shared far and wide (it was readseen by many thousands!).

But something bugged me. The custom element wrapper felt like extra ceremony. What if I could get the same instant feedback without the extra markup? Just a form, some data attributes (Rails developers ❤️ data attributes) and a sprinkle of (custom) JavaScript? 😊

Guess what? You can! And it is even simpler. 🎉

Something like this (no, really, this is not the same gif as the one from the custom elements article):

The code is available on GitHub (see the last commit).

The custom element approach looked like this:

<optimistic-form>
  <form action="<%= messages_path %>" method="post">
    <%%= text_area_tag "message[content]", nil, placeholder: "Write a message…", required: true %>

    <%= submit_tag "Send" %>
  </form>

  <template response>
    <%= render Message.new(content: "", created_at: Time.current) %>
  </template>
</optimistic-form>
Enter fullscreen mode Exit fullscreen mode

That <optimistic-form> wrapper is extra markup. The template lives inside it. You need to define the custom element, register it, manage its lifecycle. Not too bad, but it is not exactly lightweight.

What if you could just mark the form itself as optimistic?

Adding data attributes

Here is what the new version looks like:

<%= form_with model: @message,
              data: {
                optimistic: true,
                optimistic_target: "messages",
                optimistic_template: "message-template",
                optimistic_position: "prepend"
              } do |form| %>
  <%= form.text_area :content, placeholder: "Write a message…", required: true %>

  <%= form.submit "Send" %>
<%% end %>

<template id="message-template">
  <%= render Message.new(content: "", created_at: Time.current) %>
</template>

<div id="messages">
  <%= render @messages %>
</div>
Enter fullscreen mode Exit fullscreen mode

Just a regular form with some data attributes. The template lives separately (you can put it anywhere). Everything is explicit through data attributes.

The JavaScript listens for Turbo's submit-start event on any form marked with data-optimistic="true". When fired, it clones the template, populates it with the form data and then inserts it into the target. Cool, right?!

So how is this version working? Just a plain, old javascript class, really!

// app/javascript/optimistic_form.js
class OptimisticForm {
  static start() {
    document.addEventListener("turbo:submit-start", (event) => this.#startSubmit(event))
    document.addEventListener("turbo:submit-end", (event) => this.#endSubmit(event))
  }

  // private

  static #startSubmit(event) {
    const form = event.target

    if (!this.#isOptimistic(form)) return
    if (!form.checkValidity()) return

    const formData = new FormData(form)
    const element = this.#build({ form, with: formData })

    this.#insert({ element, into: form })
  }

  static #endSubmit(event) {
    const form = event.target

    if (!this.#isOptimistic(form)) return

    form.reset()
  }

  static #isOptimistic(form) {
    return form.dataset.optimistic === "true"
  }

  static #build({ form, with: formData }) {
    const template = this.#findTemplate(form)
    const element = template.content.cloneNode(true).firstElementChild

    this.#populate({ element, with: formData })

    return element
  }

  static #findTemplate(form) {
    const selector = form.dataset.optimisticTemplate

    return document.getElementById(selector)
  }

  static #populate({ element, with: formData }) {
    for (const [name, value] of formData.entries()) {
      const field = element.querySelector(`[data-field="${name}"]`)

      if (field) field.textContent = value
    }
  }

  static #insert({ element, into: form }) {
    const target = this.#findTarget(form)
    const position = form.dataset.optimisticPosition || "append"

    if (position === "prepend") {
      target.prepend(element)
    } else {
      target.append(element)
    }
  }

  static #findTarget(form) {
    const selector = form.dataset.optimisticTarget

    return document.getElementById(selector)
  }
}

OptimisticForm.start()

export default OptimisticForm
Enter fullscreen mode Exit fullscreen mode

(do not forget to import it in your entrypoint)

So how does class work? It is entirely static. No instances needed (if that sounds foreign to you, I suggest checking out JavaScript for Rails Developers). It sets up two listeners for Turbo-powered events: turbo:submit-start and turbo:submit-end.

When a form submits, check if it has data-optimistic="true". If not, ignore it. If yes, grab the form data, clone the template, populate the fields, and insert it into the target.

The #build method does this heavy lifting:

static #build({ form, with: formData }) {
  const template = this.#findTemplate(form)
  const element = template.content.cloneNode(true).firstElementChild

  this.#populate({ element, with: formData })

  return element
}
Enter fullscreen mode Exit fullscreen mode

The #populate method loops through the form data and updates any element with a matching data-field attribute:

static #populate({ element, with: formData }) {
  for (const [name, value] of formData.entries()) {
    const field = element.querySelector(`[data-field="${name}"]`)

    if (field) field.textContent = value
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the same technique from the custom element version. Your partial needs data-field attributes on the elements you want to populate:

<article class="message" id="<%= dom_id(message) %>">
  <p data-field="message[content]"><%= message.content %></p>

  <small><%= message.created_at.strftime("%Y/%m/%d") %></small>
</article>
Enter fullscreen mode Exit fullscreen mode

The #insert method handles positioning. You can prepend or append (default):

static #insert({ element, into: form }) {
  const target = this.#findTarget(form)
  const position = form.dataset.optimisticPosition || "append"

  if (position === "prepend") {
    target.prepend(element)
  } else {
    target.append(element)
  }
}
Enter fullscreen mode Exit fullscreen mode

Then there is the #endSubmit method to reset the form after submission completes. This gives instant feedback. The user types a message, hits send, the message appears in the list, and the form clears. All before the server responds. ⚡

You could handle this with a Turbo Stream instead, but keeping it in the JavaScript feels cleaner. It is part of the optimistic UX, so it belongs with the optimistic code.

On writing JavaScript well

One thing I really like about this implementation is the named parameters:

const element = this.#build({ form, with: formData })

this.#insert({ element, into: form })
this.#populate({ element, with: formData })
Enter fullscreen mode Exit fullscreen mode

These read like real sentences. 🤓 “Build an element with form data.” “Insert element into form.” “Populate element with form data.” JavaScript destructuring makes this possible:

static #build({ form, with: formData }) {
  // …
}
Enter fullscreen mode Exit fullscreen mode

The with: formData syntax renames the parameter from with (a reserved word) to formData inside the function. It is a small detail but it makes the code much more readable.

It is something I write about in my book JavaScript for Rails Developers.

Why static methods?

You might wonder why everything is static. Why not create instances? You could do this:

static start() {
  document.addEventListener("turbo:submit-start", (event) => {
    if (form.dataset.optimistic === "true") new OptimisticForm(event.target)
  })
}

constructor(form) {
  this.form = form
  // … handle submission
}
Enter fullscreen mode Exit fullscreen mode

But for this use case, instances add complexity without much benefit. We are not managing state. We are not tracking multiple submissions. We are just doing some DOM manipulation and moving on.

Static methods keep it simple. The class is really just a namespace for related functions. And that is okay! Not everything needs to be an instance.


The use cases are the same as the custom element's one, but this solution feels more Rails-like: add data-optimistic="true" to your form, point it at a template and target and you are off to the races.

Pretty cool, right? Let me know below if you try it or have questions! ❤️

Top comments (1)

Collapse
 
railsdesigner profile image
Rails Designer

Do you like this version or the custom element more? Let me know (why)!