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:
- deserialize HTML attributes (JSON serialized)
- observe HTML attributes changes: trigger re-render
- 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:
- HeadlessUI does not support shadow DOM, there are known issues.
- 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:
- 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.
- 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.