在Rails中构建乐观UI(并学习自定义元素)
Building optimistic UI in Rails (and learn custom elements)

原始链接: https://railsdesigner.com/custom-elements/

## 自定义元素:一种简单的 Web 组件方法 自定义元素允许你定义自己的 HTML 标签,并赋予它们自定义行为,属于 Web 组件范畴。它们出奇地简单直接——甚至在 Rails 的 Hotwire 中被使用,例如 `` 和 `` 标签。本质上,它们是使用 JavaScript 增强的 HTML 标签。 你定义一个类,使用 `customElements.define()` 在浏览器中注册它,然后在 HTML 中使用你的新标签。它们可以读取属性并通过 `attributeChangedCallback` 响应变化。可以使用 `is` 属性扩展现有的 HTML 元素,但兼容性(尤其是在 Safari 上)可能是一个问题。 与 Stimulus 相比,自定义元素提供了一种浏览器原生、可重用的组件方法。Stimulus 擅长增强现有的 HTML,而自定义元素则非常适合创建独立、可重用的组件。 文章演示了构建一个简单的点击计数器和一个乐观表单,该表单在服务器确认提交之前立即更新,无需页面重新加载。乐观表单利用模板渲染预览,从而提供无缝的用户体验。这种模式促进了可重用性并简化了 UI 更新,为任何 Rails 开发人员的工具包提供了一个强大的补充。GitHub 上提供代码示例。

黑客新闻 新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 在Rails中构建乐观UI(并学习自定义元素)(railsdesigner.com) 8点 由 amalinovic 1小时前 | 隐藏 | 过去 | 收藏 | 讨论 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请YC | 联系 搜索:
相关文章

原文

Custom elements are one of those web platform features that sound complicated but turn out to be surprisingly simple. If you have used Hotwire in Rails, you have already used them. Both <turbo-frame> and <turbo-stream> are custom elements. They are just HTML tags with JavaScript behavior attached.

This article walks through what custom elements are, how they compare to Stimulus controllers and how to build them yourself! Starting with a simple counter and ending with an optimistic form that updates instantly without waiting for the server. 🤯

The code is available on GitHub.

What are custom elements?

Custom elements let you define your own HTML tags with custom behavior. They fall under the Web Components umbrella, alongside Shadow DOM (encapsulated styling and markup) and templates (reusable HTML fragments), though each can be used independently. To use custom elements, just define a class, register it with the browser, and use your new tag anywhere (Shadow DOM or templates not required).

Here is the simplest possible custom element:

class HelloWorld extends HTMLElement {
  connectedCallback() {
    this.textContent = "Hello from a custom element 👋"
  }
}

customElements.define("hello-world", HelloWorld)

Now you can use <hello-world></hello-world> in your HTML and it will display the message. The connectedCallback runs when the element is added to the page. This is similar to Stimulus’s connect() method. There is also, just like with Stimulus, a disconnectedCallback. This can be used similar to Stimulus’: removing event listeners and so on.

Custom element names must contain a hyphen. This prevents conflicts with future HTML elements. So <hello-world> works, but <helloworld> does not.

Attributes and properties

Custom elements can read attributes just like regular HTML elements:

class GreetUser extends HTMLElement {
  connectedCallback() {
    const name = this.getAttribute("name") || "stranger"

    this.textContent = `Hello, ${name}!`
  }
}

customElements.define("greet-user", GreetUser)

Use it like this:

<greet-user name="Cam"></greet-user>

To react to attribute changes, use attributeChangedCallback:

class GreetUser extends HTMLElement {
  static observedAttributes = ["name"]

  connectedCallback() {
    this.#render()
  }

  attributeChangedCallback(name, oldValue, newValue) {
    this.#render()
  }

  // private

  #render() {
    const name = this.getAttribute("name") || "stranger"

    this.textContent = `Hello, ${name}!`
  }
}

The observedAttributes array tells the browser which attributes to watch. Without it, attributeChangedCallback never fires.

The is attribute

You can extend built-in HTML elements using the is attribute. For example, extending a button:

class FancyButton extends HTMLButtonElement {
  connectedCallback() {
    this.classList.add("fancy")
  }
}

customElements.define("fancy-button", FancyButton, { extends: "button" })

Then use it like:

<button is="fancy-button">Click me</button>

This keeps all the built-in button behavior while adding your custom features (simply adding the fancy class in above example). However, Safari does not support this feature. So I stick to autonomous custom elements (the hyphenated tags) for better compatibility.

Custom elements vs Stimulus

If you have used Stimulus, custom elements will feel familiar. Here is how they compare:

Feature Stimulus Custom Element
Lifecycle connect() / disconnect() connectedCallback() / disconnectedCallback()
Finding elements targets querySelector() / direct children
State values attributes + properties
Events action attributes addEventListener()
Framework Requires Stimulus Browser-native

Stimulus is great for connecting behavior to existing HTML. Custom elements are better when you want a reusable component that works anywhere. They are also simpler when you do not need Stimulus’s conventions.

The main difference is how you find elements. Stimulus uses targets:

<div data-controller="counter">
  <span data-counter-target="count">0</span>

  <button data-action="click->counter#increment">+</button>
</div>

Custom elements use standard DOM/query methods (see example below):

<click-counter>
  <span class="count">0</span>

  <button>+</button>
</click-counter>

Custom elements feel more like writing vanilla JavaScript. Stimulus is more convention-based (which is often confusing to many).

In the end it is all a regular JavaScript class. I’ve explored these extensively in the book JavaScript for Rails Developers. 💡

Building a simple counter

Time to build something. Start with a counter that increments when clicked:

// app/javascript/components/click_counter.js
class ClickCounter extends HTMLElement {
  connectedCallback() {
    this.count = 0

    this.addEventListener("click", () => this.#increment())
  }

  #increment() {
    this.count++

    this.querySelector("span").textContent = this.count
  }
}

customElements.define("click-counter", ClickCounter)

Import it in your application JavaScript:

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

import "components/click_counter"

Configure importmap to find the new directory:

# config/importmap.rb
pin_all_from "app/javascript/controllers", under: "controllers"

pin_all_from "app/javascript/components", under: "components"

Now use it in your views:

<click-counter>
  <button>Clicked <span>0</span> times</button>
</click-counter>

Click the button and watch the counter increment. Simple! 😊

Building an optimistic form

Now for something a bit more useful. Build a form that updates instantly without waiting for the server. If the save fails, show an error. If it succeeds, keep the optimistic UI.

It will look like this:

See how the message gets appended immediately and then (notice the blue Turbo progress bar) gets replaced with the server rendered version.

The HTML looks like this:

<optimistic-form>
  <form action="<%= messages_path %>" method="post">
    <%= hidden_field_tag :authenticity_token, form_authenticity_token %>

    <%= text_area_tag "message[content]", "", placeholder: "Write a message…", required: true %>

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

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

The <template response> tag (indeed also part of the Web Components standard) holds the display HTML for new messages. When the form submits, the custom element renders this template with the form values and appends it to the list. The form still submits normally to Rails.

Start with the basic structure:

// app/javascript/components/optimistic_form.js
class OptimisticForm extends HTMLElement {
  connectedCallback() {
    this.form = this.querySelector("form")
    this.template = this.querySelector("template[response]")
    this.target = document.querySelector("#messages")

    this.form.addEventListener("submit", () => this.#submit())
  }

  #submit() {
    if (!this.form.checkValidity()) return

    const formData = new FormData(this.form)
    const optimisticElement = this.#render(formData)

    this.target.append(optimisticElement)
  }
}

customElements.define("optimistic-form", OptimisticForm)

The submit method checks form validity first using the browser’s built-in validation. If the form is invalid, let the browser show its validation messages. Otherwise render the optimistic UI and let the form submit normally.

Getting optimistic

Extract the form data and populate the template:

#render(formData) {
  const element = this.template.content.cloneNode(true).firstElementChild

  element.id = "optimistic-message"

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

    if (field) field.textContent = value
  }

  return element
}

The cloneNode(true) creates a copy of the template content. Loop through the form data and update any element with a matching data-field attribute. This is why the partial has a data-field="message[content]" on the message display.

The optimistic element appears in the list immediately, then the form submits to Rails.

The Turbo Stream does not append the message, but replaces the “optimistic message” with the real one from the database:

<%# app/views/messages/create.turbo_stream.erb %>
<%= turbo_stream.replace "optimistic-message", @message %>

Since both the optimistic template and the message partial render the same HTML, the replacement is seamless. The user sees the message appear instantly, then it gets replaced with the real version (with the correct ID, timestamp, etc.) a moment later.

Here is the full implementation:

// app/javascript/components/optimistic_form.js
class OptimisticForm extends HTMLElement {
  connectedCallback() {
    this.form = this.querySelector("form")
    this.template = this.querySelector("template[response]")
    this.target = document.querySelector("#messages")

    this.form.addEventListener("submit", () => this.#submit())
    this.form.addEventListener("turbo:submit-end", () => this.#reset())
  }

  // private

  #submit() {
    if (!this.form.checkValidity()) return

    const formData = new FormData(this.form)
    const optimisticElement = this.#render(formData)

    this.target.append(optimisticElement)
  }

  #render(formData) {
    const element = this.template.content.cloneNode(true).firstElementChild

    element.id = "optimistic-message"

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

      if (field) field.textContent = value
    }

    return element
  }

  #reset() {
    this.form.reset()
  }
}

customElements.define("optimistic-form", OptimisticForm)

Do not forget to import it:

// app/javascript/application.js
import "components/optimistic_form"

Now when you submit the form, the message appears instantly in the list. The form submits to Rails, which responds with a Turbo Stream (I added sleep to mimic a slow response) that replaces the optimistic message with the real one. If the save fails, Rails can show an error message normally.

Cool, right? I’ve used this technique before with a client successfully. Many months later and it holds up nicely.


This pattern can work great for any form where you want instant feedback. Like chat messages, comments or todos. The new item appears immediately. No loading spinners, no waiting.

The key is that the partial lives right within the template element. You are not duplicating it in JavaScript. Change the partial and the optimistic UI updates automatically.

Custom elements make this pattern reusable. Drop <optimistic-form> anywhere in your app. It works with any form and any partial (with client’s project mentioned above I stubbed the partial with more advanved “stand-in” model instance).

Yet another tool in your Rails toolkit. Did this inspire you to use custom elements more too? Let me know below! ❤️

联系我们 contact @ memedata.com