Building an Embeddable Web App with LiveState, Elixir, and Lit
5 August 2022

Building an Embeddable Web App with LiveState, Elixir, and Lit

Building an Embeddable Web App with LiveState, Elixir, and Lit

For building conventional “SPA” type web apps in Elixir, LiveView is the perfect choice. It lets you write a dynamic single page web application in Elixir, no javascript required. For this article, I want to talk about how we can use Elixir to build a different type of web app app: an embeddable web app. An embeddable web app is an application that is designed to add functionality to an existing website or application. A classic use case might be this: I have a static website for my company, but I’d like to have a contact form or maybe even a live chat with customer support that I can add to my website. Rather than forcing me to move my whole website, there are vendors that provide this functionality as a “widget”, which typically means a snippet of javascript that I add to my web page that causes the contact form or chat widget to appear, often in an iframe. The amount of control I have over the appearance and behavior of the widget is proprietary and often quite limited.

Before I even get to the elixir part, I’d first like to make the case that we now have a better option for the front end of these types of apps: a custom element. Custom elements are a standard API that has been supported by all of the major browsers for several years at this point. It allow a developer to define a custom HTML element along with it’s behavior and appearance. Because it is just HTML, an author can place it anywhere on their site by adding a tag, and have control over the appearance by using CSS. It’s really the perfect technology for the embedded web app scenario.

For an embeddable web app, LiveView is no longer a viable option. To give a more “LiveView like” experience, I created an Elixir library called LiveState with a companion javascript library. To accomplish this goal, we make use of a simple pattern in web development: properties down, events up. LiveState will let us extend this pattern to the server.

For our example, we will choose something that many web pages need, but usually not something you want to write yourself: a comment section (I know, I know, never read the comments!). This is something you can find lots of examples of in the wild, and for the most part they are javascript “widgets” rather than custom elements. For our example, we want to build a custom element we can add to our own web page that will look like this:

<html>
<head>
  <script type="module" crossorigin src="https://unpkg.com/livestate-comments"></script>
</head>
<body>
  <main>
    This is my lovely website. Below are the comments
  </main>
  <livestate-comments url="wss://launch-comments.fly.dev/socket"></livestate-comments>
</body>
</html>

You can copy this html and open it in your browser. You should see some comments and a form to add more. Let’s see how this works. The first thing we’ll look at is the source code for the <livestate-comments> element:

import { html, css, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import LiveState, { connectElement } from 'phx-live-state';

type Comment = {
  author: string;
  inserted_at: Date;
  text: string;
}

export class LivestateComments extends LitElement {

  @property()
  url: string

  @state()
  comments: Array<Comment> = [];

  liveState: LiveState;

  connectedCallback() {
    super.connectedCallback();
    console.log(`connecting to ${this.url}`);
    this.liveState = new LiveState(this.url, `comments:${window.location.href}`);
    connectElement(this.liveState, this, {
      properties: ['comments'],
      events: {
        send: ['add_comment'],
        receive: ['comment_added']
      }
    });
  }
  @query('input[name="author"]')
  author: HTMLInputElement | undefined;

  @query('textarea[name="text"]')
  text: HTMLTextAreaElement | undefined;

  addComment(e: Event) {
    this.dispatchEvent(new CustomEvent('add_comment', {
      detail: {
        author: this.author?.value,
        text: this.text?.value,
        url: window.location.href
      }
    }));
    e.preventDefault();
  }

  constructor() {
    super();
    this.addEventListener("comment_added", (e) => {
      console.log(e);
      this.clearNewComment();
    });
  }

  clearNewComment() {
    this.author!.value = '';
    this.text!.value = '';
  }

  render() {
    return html`
      <div part="previous-comments">
        ${this.comments?.map(comment => html`
        <div part="comment">
          <span part="comment-author">${comment.author}</span> at <span
            part="comment-created-at">${comment.inserted_at}</span>
          <div part="comment-text">${comment.text}</div>
        </div>
        `)}
      </div>
      <div part=" new-comment">
        <form @submit=${this.addComment}>
          <div part="comment-field">
            <label>Author</label>
            <input name="author" required />
          </div>
          <div part="comment-field">
            <label>Comment</label>
            <textarea name="text" required></textarea>
          </div>
          <button>Add Comment</button>
        </form>
      </div>
    `;
  }
}

There’s a fair amount here, so let’s take this a little at a time. Right at the beginning we see our imports and the type definition of Comment. Pretty standard stuff so far. We see our custom element extends LitElement. Lit is a nifty little library that will take care of re-rendering any time the two properties we have defined, url and comments, change. The only difference between them is that url can be passed in via an attribute, but comments cannot as they a retrieved for us by LiveState.

Next, we see our LiveState instance. LiveState is designed to maintain a connection to a Phoenix Channel which we will look at shortly. The key bits it needs to make this connection are the url and the channel which are passed in as arguments to the constructor. The connectedCallback is a ideal place to create our LiveState instance, as this is the time where our element is added to the DOM so we can be sure we have the value for url available. We also have to be sure to call super.connectedCallback() so that we get the goodness provided by lit.

After we create our LiveState instance, we connect it to our element by calling connectElement. connectElement takes as arguments a LiveState instance, an HTMLElement and an options object. The options we are using are properties and events. This is where the magic happens. I mentioned earlier the idea of properties down, events up. What this means in our case is that we have a single property, comments, that is managed by LiveState. Any time it changes, LiveState will receive an update from the channel which will trigger a re-render. Conversely, any time we have something to say, we dispatch a custom even that is sent over this same channel (hence the send property). In our case, we only send one type of event, add_comment. We have another type of event that we receive from LiveState, comment_added. This allows LiveState to notify us about things that aren’t stricly state changes. We could probably have forced this to be a state change, but that would be awkward.

Below the controller, we see a couple of query decorators which are just handy ways to look up descendant DOM elements in our element’s Shadow DOM.

The addComment function is where it’s more interesting. This is our form handler, which we’ll see referenced below in our render fuction. It dispatches a CustomEvent named add_comment whose detail property is created using the two inputs on our form. That’s it! No request or response to handle, LiveState manages all the communication for us and will send the event with it’s payload over our LiveState.Channel to be handled.

Next, we see in our custructor which is where we add the event listener for the comment_added event. We use this event handler to clear our form after a comment is succesfully added.

Finally, we have our render function which displays all our comments and a form for adding new ones. Lit takes care of making sure render is called when properties change, and that it is efficient and fast.

Now let’s shift gears at look at the server side in our Phoenix app. We’ll won’t discuss the Comments context as it is mostly just boilerplate code created for us by using phx.gen.context. The real action is in CommentsChannel:

defmodule LiveStateCommentsWeb.CommentsChannel do
  use LiveState.Channel, web_module: LiveStateCommentsWeb

  alias LiveStateComments.Repo
  alias LiveStateComments.Comments
  alias LiveState.Event

  @impl true
  def state_key, do: "state"

  @impl true
  def init(_socket) do
    Phoenix.PubSub.subscribe(LiveStateComments.PubSub, "comments")
    {:ok, %{comments: Comments.list_comments()}}
  end

  @impl true
  def handle_event("add_comment", comment_params, %{comments: comments}) do
    case Comments.create_comment(comment_params) do
      {:ok, comment} ->
        {:reply, [%Event{name: "comment_added", detail: comment}], %{comments: comments ++ [comment]}}
    end
  end

  @impl true
  def handle_message({:comment_created, _comment}, state) do
    {:ok, state |> Map.put(:comments, Comments.list_comments())}
  end
end

This is a Phoenix Channel that uses our LiveState.Channel behaviour, which gives us a few callbacks to implement. The first, init, is where we subscribe to receive notifications about comments being created, and return an :ok tuple with our initial state. LiveState will take care of sending this state down to our connected clients. It’s worth noting that anything we put in state needs to have be serializable as JSON.

Next, we see our handle_event callback. This is quite similar to a Genserver callback or a React reducer: it receives an event, the current state, and returns a tuple containing an optional reply and a new state. In our case we are returning a reply, with event to dispatch on the calling element to let interested parties know that the comment was successfully added. The new state is sent over the channel to the client, where LiveState updates the comments property on the element which triggers a re-rerender.

Finally, we see our handle_message callback. This gets called when PubSub messages are received. In this case, in our Comments context we broadcast a message that looks like {:comment_created, comment} when a comment is successfully created. This lets us push any new comments down to all connected users. Note the handle_message currently returns a tuple with {:ok, new_state}, though it is likely in future versions this will allow a :reply tuple containing events to dispatch on the client if needed.

Congrats for making it this far though the post, it’s a bit of a long one! All of the code for this example, along with the LiveState libraries, are available on github:

  • The example html file above, (http://gaslight.github.io/test-livestate-comments/)
  • livestate-comments The <livestate-comments> custom element NPM package
  • livestate_comments The backend phoenix application
  • live-state The npm package for the LiveState javascript library
  • live_state The hex package for the LiveState elixir library

And last, but not least but not least, here’s our comments element in action. Feel free to share your thoughts on this article.

Related Posts

Want to learn more about the work we do?

Explore our work

Ready to start your software journey with us?

Contact Us