anchor
Insights /  
Using WebComponents inside Phoenix LiveView: Detailed Review

How to use WebComponents inside Phoenix LiveView

June 11, 2024
->
10 min read

On August 6, 1991, Berners-Lee published the first website. Yet, today — almost 33 years later, it's harder than ever to build user interfaces for web apps, and we still have no single standardised way to do that. There's a good reason for that — we simply don't know what we may need upfront. When an industrial architect designs a building, he knows his limits — they are defined by physics. But when a web designer creates and describes UI — the possibilities are limitless. And that's a problem for any standardisation process.

The Web evolving so fast, we can't get used to one set of patters as the new one emerges, and we drop whatever we were working on to embrace new trendy ideas. In fact, the speed of evolution is so fast, sometimes we drop projects half way to rewrite them using a cool new architecture, library, or framework, breaking deadlines, failing clients and end users in the process. We are striving to provide our users the best possible experience, be competitive in the market and also always stay up to date with modern tech.

Phoenix LiveView started with the crazy idea, for any fresh out of college frontend developer, that we can get back to describing our interfaces on the server again, drop JS and live a simple happy life. Like our ancestor developers did. The presentation went well, everyone was amazed, books were written, and on every Elixir Conf since then, Chris McCord kept the crowd happy with new features and improvements to the library to keep the train moving through a somehow bumpy, but very satisfying in general, ride.

And what not to like? All the complexity of state management, reactivity and templating is now handled by an extremely reliable backend instead of an unknown but large set of browser engines and their variations, which you have no control whatsoever. Because, let's face it — you just want to build user interfaces to handle the data interactions — that's how we call the features we deliver to the users. But somehow, you feel like a college graduate at their first job, learning everything all over again every single time. Who wants to build the code to handle all the little details of that flow, like we do with all the state and reactivity management libraries these days? And learn a new library API every month, just because it's 5% faster, 10% smaller, or has better syntax.

So, problem solved, right? Not really. There's one thing Phoenix LiveView is not handling well (yet), and it's a big one.

The Problem

Phoenix LiveView processes data and generates HTML on the server. This is the whole point, but it has one big downside (setting aside the latency issue for now) — no access to the DOM. And in addition, it does leave you with a plain set of HTML native elements. Sorry, no fancy date/time pickers with ranges, comboboxes, tag inputs, tooltips, menus, drop-downs, popovers, drawers, switches, toggles and so on. Well, that's a disappointment for sure. LiveView fixes the complexity issue, but does not fix the complex UI building difficulty.

Well, you may say, it's easily fixable with CSS. Just add DaisyUI or Flowbite and it's done! Or, pay a buck for something like PETAL — why not?

Those solutions are perfectly fine for some cases, but they are very limited. CSS will not provide you with proper a11y, visual viewport awareness, keyboard navigation, focus management, and so on. Premium solutions will still rely heavily on third-party Javascript, locking you into their limitations.

Ouch! But wait — I can still implement complex UI parts in React or something similar and call it a day, right?

Not so fast, sailor! Plugging every hole in your sinking boat with JS may sound cool and even trendy (island architecture, micro-frontends — heard of those?), but the result may be less than satisfying. Maintaining multiple technologies and re-using established patterns in other projects would be difficult. Of course, it's still doable, but you can say goodbye to that "single source of truth" dreamy design system your design lead keeps bragging about during every retrospective. Good luck explaining why you need to implement the same button component 5 times because there are React, Vue, Phoenix components, and a few vanilla JS libraries for things you just can't reuse within each other.

And that's the main problem! Client-side JS components built with particular frameworks are not composable. Moreover, they are not composable with Phoenix Components and vice versa. Some may argue it's still okay, as LiveView shields us from the bigger evil—client-side complexity. But there's a solution capable of solving most of those issues if not all of them.

The Solution

Enter "Web Components"! Yeah, it does not sound fancy or trendy at all. It's a common name for a set of JavaScript APIs built into browsers long ago and has evolved ever since. Yes, you get it right — it's already in the browser — no need to download and bundle anything. And it won't disappear tomorrow because some corporation stops supporting its development. That's the beauty of standards, specifically — W3C standards.

It does require some JavaScript here and there, but it does not necessarily have to be written by you — open-source components and design systems are available just to grab and use. The best part is that you can use it anywhere in your code, including any other client-side JS code.

If you are eager to learn more about web components — please read the MDN and web.dev docs. They cover many details and answer all possible questions about this technology. To understand how it all started, you will have to go back to 2011 and watch this remarkable presentation (please, watch until the end, it's worth it) —

The Example

Imagine a real-life case where you need to handle icons in your Phoenix app. To make it even more real, your app has some complex client-side JS components, like react-select. Your designer draws icons somewhere in Figma, and you export them to SVG sprite. Now, you need to put them within Phoenix HEEX and React JSX. Double trouble you got there now. Let's try creating a HEEX component first. It will look something like this:

1defmodule MyAwesomeApp.Components.Icon do
2  use Phoenix.Component
3
4  attr :name, :string, required: true
5  attr :size, :number, default: 24
6	
7  def icon(assigns) do
8    ~H"""
9    <svg width={@size} height={@size}>
10      <use xlink:href="/icons/sprite.svg##{@name}" />
11    </svg>
12    """
13  end
14end

And then, in your templates, you can use it like this:

<.icon name="plus" />

Easy-peasy. Now, you just must use it within JSX inside the react component. Because real-life projects are complex. Unfortunately, there's no way to pass this as a prop. You need to create a new component:

1export const Icon = ({ name, size }) => {
2  return (
3    <svg width={size} height={size}>
4	  <use xlink:href=`/icons/sprite.svg#${name}` />
5    </svg>
6  )
7}

So, now you can use it with your react-select with:

1import React from 'react';
2import Select from 'react-select';
3import { Icon } from './Icon'
4
5const CrossIcon = () => 
6  <Icon name="x" size={14} />
7
8const DownChevron = () =>
9  <Icon name="down-chevron" size={14} />
10
11export AwesomeSelect = () =>
12  <Select components={{ CrossIcon, DownChevron }} />

Ok, done. But there's a problem now. If we had to change the path of the sprite or change the implementation, we would have to make changes in two places. And as your app grows, the duplication will grow with it. We must find a way to define this component once and use it everywhere.

Well, that's what Web Components are for. Remember the "works everywhere" part from a few paragraphs above? Let's write our first web component, shall we?

1class Icon extends HTMLElement {
2  connectedCallback() {
3    this.innerHTML = `
4      <svg 
5        width="${this.getAttribute("size")}"
6        height="${this.getAttribute("size")}"
7      >
8        <use xlink:href="/icons/sprite.svg#${this.getAttribute("name")}" />
9      </svg>`;
10    }
11  }
12}
13
14customElements.define("my-icon", Icon);

This is an elementary example, but you can do it without any external libraries. Even though it may look a bit ugly, you are defining a new HTML tag here — how cool is that? Just remember that it has to be with - dash in its name to avoid collision with HTML tags from spec. You have to add it to the so-called "Custom Elements Registry" — hence, the define(...) part. Now, if we would rewrite our HEEX and JSX code using this, it will look like this:

1defmodule MyAwesomeApp.Components.Icon do
2  use Phoenix.Component
3
4  attr :name, :string, required: true
5  attr :size, :number, default: 24
6	
7  def .icon(assigns) do
8    ~H"""
9    <my-icon size={@size} name={@size}></my-icon>
10    """
11  end
12end

And this:

1import React from 'react';
2import Select from 'react-select';
3
4const CrossIcon = () => <my-icon name="x" size="14"></my-icon>
5const DownChevron = () => <my-icon name="down-chevron" size="14"></my-icon>
6
7export AwesomeSelect = () => <Select components={{ CrossIcon, DownChevron }} />

Notice that our implementation is completely independent now, and you have a new HTML tag, my-icon. Going back to LiveView works only with HTML — as you can see, this is usable everywhere now! You can even wrap your HEEX with Web Components, and it will work perfectly fine with LiveView. This means you can create modals, drawers, and so on and put HEEX inside of it, and LiveView will still track them and process updates even though part of the DOM tree is created on the client side. And yes — you can observe attribute changes, put lifecycle listeners, react to external and internal events, and so on.

LiveView Integration Part

So, now let's get to the most exciting part — how to wire all this cool stuff with LiveView. It's actually not hard at all. It is much easier compared to most client-side frameworks. But some code must be written, some LiveView Hooks need to be wired, and some external dependencies must be added if you want to have a nice component library out of the box.

First, you need to figure out if you want to write your components or use an established library. We recommend checking out Shoelace — it has many valuable components, customization options, and straightforward integration. For some time, web components' limitations were the difficulty of handling form elements between regular light and shadow DOM (it's not an issue anymore as of 2023). Shoelace handles that for you, and you should always check with whatever library you use form components to support interacting with light DOM forms. The good thing is that you can mix and match, and everything will work everywhere (hopefully), so there is no stress — multiple libraries can be used together. Some other options are:

Ok, let's say you picked Shoelace and decided you want to use some of its components in your app. For simplicity, let's just put CDN import in your root layout somewhere:

1...
2<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.15.0/cdn/themes/light.css" />
3<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.15.0/cdn/shoelace-autoloader.js"></script>
4...

This code will autoload web components if you define them somewhere in your DOM tree. There are many ways to install this library, but this is the easiest to start with. Every Phoenix app will have a single point of entry JS file, where you have a LiveView connection setup. We need to make some changes there:

1let liveSocket = new LiveSocket("/live", Socket, {
2  longPollFallbackMs: 2500,
3  params: {
4    _csrf_token: csrfToken
5  },
6
7  // Add this code.
8  dom: {
9    onBeforeElUpdated: (from, to) => {
10      if (from.tagName.startsWith("SL-")) {
11        [...to.attributes, ...from.attributes].forEach((attr) => {
12          to.setAttribute(attr.name, attr.value); 
13        });
14      },
15    },
16  }
17)

It's essential because LiveView works on the server, but web components work on the client. This means a web component could change element attributes on the client while LiveView does nothing on the server, and on the following diff, morphdom library will just remove those client-side attributes. In practice, this means, for example, that if your dropdown is open, it will have an open attribute on the client. Suppose LiveView sends a diff now from the server without that attribute. In that case, it will be closed — remember that for HTML tags, boolean attributes are set to true when they are present ( === open is true) and false when they are not ( === open is false). The code above will tell LiveView to merge client attributes with those from the server. Sometimes, you may want to set the phx-update="ignore" attribute to avoid LiveView updating your component because it handles much of its state on the client. You can now use any of Shoelace components easily in your Phoenix templates code (both "live" and "dead" views):

1defmodule MyAwesomeApp.Pages.IndexLive do
2  use Phoenix.LiveView
3
4  def render(assigns) do
5    ~H"""
6    <sl-dropdown>
7      <sl-button slot="trigger" caret>Awesome Dropdown</sl-button>
8      <sl-menu>
9        <sl-menu-item 
10          :for={item <- @items}
11          value={item.id}
12          phx-click="select" 
13          phx-value-id={item.id}
14        >
15          {item.name}
16        </sl-menu-item>
17      </sl-menu>
18    </sl-dropdown>
19    """
20  end
21
22  def handle_event("select", %{"id" => id}, socket) do
23    # Do something with the selected item from dropdown
24    result = SomeModule.some_method(id)
25
26    {:noreply, assign(socket, items: result)}
27  end
28end

Nice, right? You can add LiveView phx- prefixed attributes to web components, which will work as expected. You can also do things more complexly by assigning LiveView JS Hooks to your components and doing something in JS code before sending results back. Let me give you an example:

1defmodule MyAwesomeApp.Pages.IndexLive do
2  use Phoenix.LiveView
3
4  def render(assigns) do
5    ~H"""
6    <sl-dropdown id="awesome-dropdown" phx-hook="Dropdown">
7      <sl-button slot="trigger" caret>Awesome Dropdown</sl-button>
8      <sl-menu>
9        <sl-menu-item 
10          :for={item <- @items}
11          value={item.id}
12        >
13          {item.name}
14        </sl-menu-item>
15      </sl-menu>
16    </sl-dropdown>
17    """
18  end
19
20  def handle_event("select", %{"id" => id}, socket) do
21    # Do something with the selected item from dropdown
22    result = SomeModule.so_method(id)
23
24    {:noreply, assign(socket, items: result)}
25  end
26end

Check the events for your component — https://shoelace.style/components/menu#events and wire them to LiveView.

1// Dropdown Hook
2export const Dropdown = {
3  mounted() {
4    // Get sl-menu element - we will be listening to its 'sl-select' event
5    const menuEl = this.el.querySelector('sl-menu)
6
7    // Add event listener
8    this.el.addEventListener("sl-select", (event) => {
9      // Push data back to LiveView's "handle_event" handler
10      this.pushEvent("select", {
11        id: event.detail.item.value
12      })
13    })
14  }
15}
16

Don't forget to wire your hook in app.js (or however you call your JS entry file):

1import { Dropdown } from './Dropdown.js'
2
3let liveSocket = new LiveSocket("/live", Socket, {
4  longPollFallbackMs: 2500,
5  params: {
6    _csrf_token: csrfToken
7  },
8
9  dom: {
10    onBeforeElUpdated: (from, to) => {
11      if (from.tagName.startsWith("SL-")) {
12        [...to.attributes, ...from.attributes].forEach((attr) => {
13          to.setAttribute(attr.name, attr.value); 
14        });
15      },
16    },
17  },
18
19  // Add this code
20  hooks: {
21    Dropdown
22  }
23)
24

Remember, components with hooks should always have a unique ID attribute!

That wasn't so hard. You can do even more cool stuff, like add event listeners to your component and open/close it directly from the backend by sending an event to it using its ID attribute. In general, what you can do is only limited by your imagination here, as now we have a channel for the data to flow. There is no need for complex APIs layers and heavy frontend frameworks.

Limitations

There are some limitations you should be warned upfront:

{{bb28-1="/custom-block-to-blog/four-page"}}

Conclusions

I'm maintaining a fleet of about 30+ web components in the Phoenix project, which have been in production for a few years. We have a mix of Phoenix components, vanilla JS libraries and React. Web Components are the glue that holds everything together. Because of their flexibility, we built a design system with reusable components that we can put anywhere, and they will just work without any code duplications or separate API data communication layers. It saved the project by offloading resources for required feature development and delivery to our clients.

Don't want to take my word for it? Well, Adobe built a web version of Photoshop with Web Components! That's something.

I see the bright future for this technology as new and more valuable APIs get implemented and delivered every year. React 19 will have a compiler that can output web components, Solid has Solid Element, and Svelte has this built in. Imba also outputs web components, and many libraries can help you write your components in any way you want, reducing the boilerplate, like:

  • Lit from Google
  • FAST from Microsoft
  • Hybrids for functional style without classes
  • Atomico, if you want a more React-like experience

After many years of complex front-end development, we can regain our simplicity with declarative UI, using HTML markup and some small JS parts here and there.

If you want to write your components once and use them everywhere, Web Components is your best bet. And with some tips from this article, you could make it work in your Phoenix project.

{{about-topolianskyi="/material/static-element"}}

Shall we discuss
your idea?
Uploading...
fileuploaded.jpg
Upload failed. Max size for files is 10 MB.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
What happens after you fill this form?
We review your inquiry and respond within 24 hours
We hold a discovery call to discuss your needs
We map the delivery flow and manage the paperwork
You receive a tailored budget and timeline estimation