DEV Community

Rails Designer
Rails Designer

Posted on Originally published at railsdesigner.com on

Lazy loading, optimistic UI and more in Rails with Attractive.js

This is the last article of a series where I introduce Attractive.js 1.0.0 (in pre-release now).

In this last article I want to highlight how Attractive can be a nice addition and in some cases a complete replacement for Hotwire.

I created a simple message board in Rails. No Stimulus anywhere, nor Turbo Streams. Check it out!

rails-attractive-demo.jpg

Most of the features of the is build one Attractive extension I intentionaly left out last week; Attract. It serves two purposes:

  • intercept form request and send fetch instead (we know that technique!), and:
  • work with attract JSON responses to render HTML based off of template elements

One partial, three uses

The whole thing uses one single partial for a message:

<%# locals: (message:) %>
<li class="message" id="message-<%= message.id %>">
  <small class="author" attract-field="author"><%= message.author %></small>

  <p class="body" attract-field="body"><%= message.body %></p>
</li>

Enter fullscreen mode Exit fullscreen mode

This one partial renders the initial page, drives the optimistic UI and powers the lazy load. Say what?! 🤯 The attract-field attributes tell Attractive’s Attract where to slot data when it renders the template.

Client and server validation

The message body validates on the client with the native Constraint Validation API, via the @validate extensions (mentioned last week). Just required and minlength attributes, plus one attribute on the form:

<form @validate @attract >
  <input id="author" name="author" type="text" placeholder="Your name">

  <textarea name="body" required minlength="3"></textarea>
</form>

Enter fullscreen mode Exit fullscreen mode

The name field, by contrast, is deliberately left without a client rule. Its validation comes from the server. When Rails rejects it, the errors come back as JSON and get wired into the same native validation UI. Client and server errors render identically.

Optimistic UI

The form has @attract, so submitting it sends a fetch instead of a page reload. A cached <template> renders an optimistic preview instantly, from that same partial, while the server processes the request:

<p><strong @text="message_count">41</strong> messages</p>

<form
  id="new-message"
  @attract
  data-attract-template="message"
  data-attract-target="messages"
  data-attract-position="prepend"
></form>

<ul id="messages">
  <%= render @messages %>
</ul>

<template id="message">
  <%= render "messages/message", message: Message.new %>
</template>

Enter fullscreen mode Exit fullscreen mode

In the example app, the create request sleeps for a second so you can actually see the optimistic message appear before the real response. When it comes back, it holds the actions:

{
  "actions": [
    { "action": "setStore#message_count=42" },
    { "action": "reset", "target": "new-message" }
  ]
}

Enter fullscreen mode Exit fullscreen mode

Multiple actions in one response once the message is saved: set the message count using Reactive’s store and a (bundled) action to reset the form.

Attract brings 6 more actions to the table. They almost all clone a cached template (like #message above):

  • append to append to target
  • prepend to prepend to target
  • replace to replace target element
  • before to insert before target
  • after to clone template, insert after target

And the odd one:

  • remove to remove target element(s)

The nice thing is, you can use these actions solo without attract as well, like I do for my flash (toast) messages:

<%# locals: (message:, icon: :check, icon_css: …, css: …) %>
<li @action="remove:mounted" data-delay="7500" class="…">
  <%= tag.p safe_join([icon(icon, class: icon_css), message]), aria: {live: :polite}, class: css %>
</li>

Enter fullscreen mode Exit fullscreen mode

The message gets removed from the DOM after a delay of 7500ms.

Lazy loading messages

The message list loads five at a time. At the bottom, a simple form GET request that is triggered when in view (using the bundled whenInView trigger):

<form
  method="get"
  action="/messages?offset=5"
  @action="submit:whenInView"
  @attract
></form>

Enter fullscreen mode Exit fullscreen mode

The server replies with a sequence of actions: append the next batch, when there are more, swap in a fresh “lazy load action” and point it at the next offset or remove it when the list runs out.

render json: {
  actions: [
    { action: "append", target: "messages", template: "message", data: batch },
    { action: "replace", target: "load-more", template: "load-more", data: {} },
    { action: "setAttribute#action=/messages?offset=10", target: "load-more" }
  ]
}

Enter fullscreen mode Exit fullscreen mode

Three actions, one request.

Keyboard shortcuts

The Attractive Element scopes the filter and the keyboard extension wires the shortcuts. @hotkey.meta+k focuses the filter, @keydown.escape clears it, @keydown.meta+enter submits the form.


And that is how Attractive can work alongside Hotwire or replace it; depending on your needs. I am quite excited how this all turned out. It has been multiple months in the making (excluding the time noodling it over in my brain).

I would love for you to give it a try, just the bundled actions for your (Perron-powered 💡) site, along-side Hotwire or even replacing it. Let met know how it goes.

Things I am thinking about adding is support for SSE so you can “broadcast” actions to many and also an attractivejs-rails gem (to remove some of the ceremony you see in the Rails demo app).

Get started with the quickstart and give the repo a star or fix a bug you find! ✨😊

Top comments (0)