Phoenix LiveView + Headless UI

How to use react and web components to include Headless UI components in Phoenix LiveView.


tl;dr https://github.com/ftes/phoenix-headlessui/

This article describes how to use Headless UI Javascript components in Phoenix LiveView using Web Components.

The JS components serve as "sprinkles" to fill the small gap that LiveView leaves: Feature-rich, well-tested client-side components that provide immediate user feedback, for example comboboxes.

The described approach notably

  • ✅ builds the JS components in React using Headless UI,
  • ✅ wraps the JS component as a web component (more specifically: as a custom element),
  • ✅ is not limited to Headless UI, but allows for arbitrary react components,
  • ✅ maintains the minimal esbuild based assets pipeline,
  • ✅ stores durable state server-side (e.g. selected value in a combobox),
  • ✅ stores ephemeral state client-side (e.g. query string in a combobox),
  • ✅ integrates with LiveView forms.

On the downside, this approach does not

  • ❌ build the JS components in Vue.js,
  • ❌ render the components to the shadow DOM.

Why Javascript components?

Phoenix LiveView streamlined JS integration in the 0.17.0 release. The JS module allows building intermediate-level client-only interactions from within heex/Elixir. A good example is showing/hiding a modal. If that covers all your needs: great. Stick with Elixir only, and keep things simple. However, most real-world web apps need to do a little more:

  • handle client-side state
  • use existing, feature-rich, well-tested JS component libraries.

This article describes how to fill this gap.

The idea

The following sequence diagram illustrates the interaction of server (LiveView), Web and React components and the user. The repo includes a more detailed version.

The selected value, which is the durable state, is stored server-side in the LiveView. The server also usually provides the available select options (omitted from the diagram for brevity).

The Web component acts as an intermediary between LiveView and React component. It has three responsibilities:

  1. deserialize HTML attributes (JSON serialized)
  2. observe HTML attributes changes: trigger re-render
  3. forward client-side events to server via LiveView websocket

The code

https://github.com/ftes/phoenix-headlessui

This repo contains a working demo. These are the most interesting parts:

# home_live.ex
defmodule PhoenixHeadlessuiWeb.HomeLive do
  @options [
    %{value: "ck", label: "Clark Kent"}
  ]

  def render(assigns) do
    ~H"""
    <x-combobox
      phx-hook="PushEvent"
      phx-update="ignore"
      id="react-combobox"
      options={Jason.encode!(@options)}
      value={@selected}
    />
    """
  end

  def handle_event("select", %{"value" => value}, socket) do
    {:noreply, assign(socket, :selected, value)}
  end
// PushEventHook.js
const PushEventHook = {
  mounted() {
    const target = this.el.attributes["phx-target"]?.value

    this.el.__pushEvent = (event, value, onReply = () => {}) =>
      target
        ? this.pushEventTo(target, event, value, onReply)
        : this.pushEvent(event, value, onReply)
// WebComponent.jsx
export default class ComboboxWebComponent extends HTMLElement {
  connectedCallback() {
    // ...
    this.render()
  }

  attributeChangedCallback(attr, value) {
    this.render()
  }

  render() {
    const options = JSON.parse(this.getAttribute('options'))
    const value = this.getAttribute('value')
    const onSelect = ({ value }) => {
      this.__pushEvent("select", { value })
    }

    this.__reactRoot.render(
      <Combobox options={options} value={value} onSelect={onSelect} />
    )
  }

  // ...
}

customElements.define('x-combobox', ComboboxWebComponent)

home.ex contains a simple LiveView that renders x-combobox, the custom element (Web component). It serializes complex data as JSON (Jason.encode!(options)). The React component is in charge of the DOM, so phx-update="ignore" tells LiveView to let React do its thing without interfering. Attribute changes x-combobox element, e.g. the selected value , are still pushed to the client.

PushEventHook.js hook exposes the LiveView JS API to the Web component by binding it to the DOM element as el.__pushEvent.

WebComponent.jsx handles the three responsibilities mentioned above.

Make sure to add --jsx-import-source=react --jsx=automatic to your esbuild default in config.exs.

That's it. Simple, extensible, no magic.

Form integration

LiveView forms and the immediate validation they offer are somewhat magic. We can integrate nicely with such a form using a hidden input as a "bridge". The following code highlights the changes required to integrate with a LiveView form:

// FormWebComponent.jsx
export default class ComboboxFormWebcomponent extends HTMLElement {
  connectedCallback() {
    // ...
    const inputId = this.getAttribute("input-id")
    this.inputEl = document.getElementById(inputId)
    this.inputEl.addEventListener("change", this.onValueChange)
    this.render()
  }

  disconnectedCallback() {
    this.inputEl.removeEventListener("change", this.onValueChange)
  }

  render() {
    // ...

    const options = JSON.parse(this.getAttribute('options'))
    const value = this.inputEl.getAttribute('value')
    const onSelect = ({ value }) => {
      this.inputEl.value = value
      // https://hexdocs.pm/phoenix_live_view/js-interop.html#triggering-phx-form-events-with-javascript
      this.inputEl.dispatchEvent(new Event("input", {bubbles: true}))
    }

    this.__reactRoot.render(
      <Combobox options={options} value={value} onSelect={onSelect} />
    )
  }

  // ...

  onValueChange = () => this.render()
}
# home_live.ex
defmodule PhoenixHeadlessuiWeb.HomeLive do
  @impl true
  def render(assigns) do
    ~H"""
    <.form let={f} for={@changeset} phx-change="validate" phx-submit="save">
      <%= label f, :assignee %>
      <%= hidden_input f, :assignee, id: "assignee-input" %>
      <x-combobox-form input-id="assignee-input" phx-update="ignore" id="react-form-combobox" options={Jason.encode!(@options)} />
      <%= error_tag f, :assignee %>
      <%= submit "Save" %>
    </.form>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket
    |> assign(:options, @options)
    |> assign(:changeset, Form.changeset(%Form{}))}
  end

Here, we rely on the hidden input for the bidirectional communication. The custom element scrapes the value from the hidden input. And by dispatching a DOM input event, a user change is automatically pushed to the server. This triggers validation, and removes the need for the PushEvent hook.

Why not…?

Shadow DOM?

For two reasons:

  1. HeadlessUI does not support shadow DOM, there are known issues.
  2. Encapsulated CSS styles. Likely you style via Tailwind and bundle a single app.css in your assets build. The light DOM styles by design do not apply to the shadow DOM. So you either end up <link-ing your main stylesheet in the shadow DOM as well. Or creating a complex build pipeline to generate stylesheets per component.

Shadow DOM would allow for cleaner separation (no phx-update="ignore") and the ability to pass data and markup (e.g. slots) as HTML children nodes.

Also, for the form case, the hidden input could be passed to the custom element as a light DOM child. This would remove the need for finding it via ID.

But, for the use cases I have in mind, I think this is not necessary.

Vue.js?

The answer is again twofold:

  1. Single File Components (SFCs) don't mix well with the simplistic esbuild pipeline in Phoenix LiveView. Vue.js with JSX is firstly a mess (events, slots) and secondly prevents you from using the HeadlessUI Vue.js examples.
  2. defineCustomElement, the Vue.js provided Web component wrapper, has two problems. Firstly, it does not support rendering to light DOM (see problems with shadow DOM above). Secondly, it only deserializes basic prop types, but not complex types such as arrays, e.g. options in our example.

For an attempt at using Vue.js, see the vue branch.

Preact?

Why not minimize bundle size with Preact? HeadlessUI has no Preact support. Replacing React with Preact in the HeadlessUI dependencies likely requires an involved esbuild config. I found instructions for webpack, not for esbuild. Mind you, I didn't dig deep into this.

The future

What is left do be investigated and discussed?

  • Delete this. If there is a better way of doing this with LiveView only, I'm all for it. Right now I propose there isn't.
  • Shadow DOM and slots: If HeadlessUI supports Web components in the future this is worth investigating. Ideally, HeadlessUI would provide each of its components as a custom element. That would eliminate the need for a custom wrapper. I originally thought about doing that wrapping myself. But I think the way in which HeadlessUI components are built up using slots and introspection on child elements precludes an easy way of doing this as an outsider.
  • What is the effect on bundle size? How performant is this? From what I see without having measured it, this is not an issue. The main HTML is there immediately, it is OK if the the rich JS sprinkles take a bit longer to load.

Further reading