DEV Community

Cover image for LiveState for Elixir: An Overview and How to Build Embeddable Web Apps
Pulkit Goyal for AppSignal

Posted on • Originally published at blog.appsignal.com

LiveState for Elixir: An Overview and How to Build Embeddable Web Apps

If you have programmed with Phoenix, you already know what a delight it can be to work with LiveView. LiveView simplifies your development process by moving all state management to the server. This reduces the complexity of coordinating states between the client and server.

LiveState aims to extend a LiveView-like development flow to embeddable web apps. But before we delve deeper into LiveState, let’s first understand what embeddable web apps are.

Embeddable Web Apps

Embeddable web apps are applications designed to be embedded within another website. These apps are typically small and focus on a specific feature.

Examples include:

  • A comments section on a blog post
  • A contact form on an otherwise static website
  • A chat bubble or support widget

LiveState brings the ease and efficiency of the LiveView workflow to the development of these embeddable web apps.

What is LiveState for Elixir?

LiveState is a framework that simplifies creating embeddable web applications by maintaining a server's state.

This has several benefits:

  • Centralized State Management: You can avoid the complexities of synchronizing state between the client and server.
  • Real-time Updates: LiveState enables real-time updates, ensuring that users always see the most current data without needing to refresh the page.
  • Reduced Client Complexity: Client-side code can remain simple and lightweight.
  • Enhanced Security: Sensitive data is less exposed to potential security threats on the client side.

As we will see later in this post, building robust and dynamic embeddable web apps using LiveState is straightforward. With minimal effort, you can leverage the power and simplicity of server-side state management.

More Use Cases

In addition to embeddable web apps, LiveState excels in another important area: highly interactive client components. These components can even exist within a traditional LiveView app, where certain parts of the application are managed using LiveState alongside client-side JavaScript frameworks like React, SolidJS, or LitComponents.

Within a traditional LiveView app, some sections might require more interactivity and a richer client-side experience. LiveState can manage these highly interactive parts using client-side frameworks like React, SolidJS, or LitComponents, while still leveraging the server-side state management benefits of LiveView.

Choosing Between LiveView and LiveState

When deciding whether to use LiveView or LiveState, consider the following:

  • LiveView: Best suited for applications where most of the interactivity and state management can be handled on the server. This simplifies the client-side code and leverages the power of Elixir and Phoenix to manage state and interactivity efficiently.
  • LiveState: Ideal for scenarios requiring embeddable features or highly interactive client-side components. If parts of your app need to be embedded in other websites or require rich interactivity that benefits from JavaScript frameworks, LiveState offers a flexible and powerful solution.

Example: Creating An Embeddable Phoenix Web App

Let’s see how LiveState works in practice by integrating it into a Phoenix app to create an embeddable web app. We'll build a LiveState component to manage a to-do list.

We'll also build a Phoenix channel backend and use LitElement for our front-end component. The front-end component will mostly be plumbing — we'll use LiveState to manage the state and events on the Phoenix backend, similar to LiveView.

Step 1: Add Dependencies

First, add the necessary dependencies to your mix.exs file:

defmodule TodoApp.MixProject do
  use Mix.Project

  defp deps do
    [
      # ...
      {:live_state, "~> 0.8.1"},
      {:cors_plug, "~> 3.0"}
    ]
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 2: Update Your Endpoint

Next, update your Endpoint to set up a socket for the channel:

defmodule TodoAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :todo_app

  socket "/socket", TodoAppWeb.LiveStateSocket
end
Enter fullscreen mode Exit fullscreen mode

Step 3: Create the Phoenix.Socket

Then create the Phoenix.Socket:

defmodule TodoAppWeb.LiveStateSocket do
  use Phoenix.Socket

  channel "todos:*", TodoAppWeb.TodosChannel
  @impl true
  def connect(_params, socket), do: {:ok, socket}

  @impl true
  def id(_), do: "todos:*"
end
Enter fullscreen mode Exit fullscreen mode

This is a standard Phoenix Socket. If you have worked with channels before, you may already be familiar with how this works. For new users, the channel macro defines a channel matching a given topic.

Note that we use simple implementations for the connect and id callbacks that allow all connections. This keeps the guide straightforward, but in a real-world app, you would likely modify them to accept only authenticated users.

Step 4: Define the Channel

Now, let's define the channel.
This will be the core of our backend, which will be responsible for maintaining the server-side state and handling events dispatched from the client side. It functions similarly to a Phoenix.LiveView in a traditional setup.

defmodule TodoAppWeb.TodosChannel do
  use LiveState.Channel, web_module: TodoAppWeb

  alias LiveState.Event

  @impl true
  def init(_channel, _payload, _socket) do
    {:ok, %{todos: list_todos()}}
  end

  defp list_todos() do
    Todos.list_todos()
    |> Enum.map(&todo/1)
  end

  defp todo(%Todos.Todo{} = todo), do: Map.take(todo, [:id, :title, :completed])
end
Enter fullscreen mode Exit fullscreen mode

This is the most basic setup for the channel. Let's break it down.

A new channel is initialized as soon as a client connects to the WebSocket and subscribes to the todos:* topic. In the init callback, we add all existing todos to the current state, making this state available to the client side.

We will expand on this module later. First, let's look at the final part of our setup: the client component.

Build the Front-End Component with LitElement

We'll use LitElement, so navigate to the assets directory and install lit and phx-live-state.

# assets
$ npm install lit phx-live-state --save
Enter fullscreen mode Exit fullscreen mode

If you are new to LitElement and custom HTML elements, now is a good time to review the basic concepts.

Next, create a new custom element to render the todos inside assets/js:

// assets/js/modules/todos/TodoListElement.ts
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import LiveState, { connectElement } from "phx-live-state";

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

@customElement("todo-list")
export class TodoListElement extends LitElement {
  @property({ type: String })
  url: string = "";

  @state()
  todos: Array<Todo> = [];

  connectedCallback() {
    super.connectedCallback();
    const liveState = new LiveState({
      url: this.url,
      topic: `todos:${window.location.href}`,
    });

    connectElement(liveState, this, {
      properties: ["todos"],
    });
  }

  render() {
    return html`
      <ul style="list-style-type: none">
        ${this.todos?.map(
          (todo) => html`
            <li>
              <input
                type="checkbox"
                id=${`todo-${todo.id}`}
                ?checked=${todo.completed}
              />
              <label for=${`todo-${todo.id}`}>${todo.title}</label>
            </li>
          `
        )}
      </ul>
    `;
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's break this down.

  • TodoListElement Component

First, we define a new LitElement component named TodoListElement using the @customElement decorator. This component is responsible for fetching and displaying a list of todos.

@property: The url property specifies the endpoint for fetching todos.

@state: The todos array holds the list of to-dos fetched from the server.

  • LiveState Integration

In the connectedCallback method, we set up LiveState to synchronize our component's state with the server.

LiveState is configured with the specified URL and a topic derived from the current page's URL. Using the page's URL is just an example; it can be utilized on the server side to separate to-dos based on the page the component is embedded on.

The connectElement function links the todos state in our component with the LiveState instance, ensuring real-time updates.

There are additional options available with connectElement that we will discuss later.

  • Rendering the To-do List

The render method generates the HTML structure for our to-do list, displaying each to-do item with a checkbox and label.

Now you can include this component inside your app's JavaScript file (assets/js/app.js):

import "./modules/todos/TodoListElement";
Enter fullscreen mode Exit fullscreen mode

This will make the component available for use. Since we configured it as a custom HTML element, it's simple to use on a page. You can just use the todo-list element and pass a URL (configured as a property) to it.

<todo-list url="ws://127.0.0.1:4000/socket"></todo-list>
Enter fullscreen mode Exit fullscreen mode

If you already have some to-dos, you should now start seeing them on the page.

Adding New To-dos

Next, let's add a way to create new to-dos.

First, we'll update the client to create new to-dos. Modify the connectedCallback to send an add_todo event and receive the todo_created event from the server.

connectedCallback() {
  // ...
  connectElement(liveState, this, {
    properties: ['todos'],
    events: {
      send: ['add_todo'],
      receive: ['todo_created']
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Then update the render method to include a form that will send the add_todo event to the server:

render() {
  return html`
    <ul style="list-style-type: none">
      ${this.todos?.map(todo => html`
        <li>
          <input type="checkbox" id=${`todo-${todo.id}`} ?checked=${todo.completed}>
          <label for=${`todo-${todo.id}`}>${todo.title}</label>
        </li>
      `)}
    </ul>
    <div>
      <form @submit=${this.addTodo}>
        <div>
          <label for="todo">Todo</label>
          <input id="todo" name="title" required>
        </div>
        <button>Add Todo</button>
      </form>
    </div>
  `;
}
Enter fullscreen mode Exit fullscreen mode

Notice that we referenced the addTodo method in the form's @submit callback.
Let's add that method as well:

@query('input[name="title"]')
titleInput: HTMLInputElement | undefined;

addTodo(e: Event) {
  this.dispatchEvent(new CustomEvent('add_todo', {
    detail: {
      title: this.titleInput?.value
    }
  }));
  e.preventDefault();
}
Enter fullscreen mode Exit fullscreen mode

The implementation is straightforward. We need to get the value of the title input and dispatch an event. The event name should match what we configured to send inside the connectElement call.

We used another directive, @query, from LitElement to query the title input. This is a convenient method to find the element matching a selector.

Server-Side Handling

On the server side, we need to handle the add_todo event to create a new to-do and update the state. Let's go back to the TodosChannel and add this functionality.

defmodule TodoAppWeb.TodosChannel do
  alias LiveState.Event

  @impl true
  def handle_event("add_todo", todo_params, %{todos: todos}) do
    case Todos.create_todo(todo_params) do
      {:ok, t} ->
        data = todo(t)
        {:reply, [%Event{name: "todo_created", detail: data}], %{todos: todos ++ [data]}}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

LiveState calls the handle_event callback when events are dispatched from the client. The event's detail is passed from the client as the second argument to the handle_event callback, while the third argument is the existing channel state, which contains the current to-dos.

In our implementation, we simply create a new to-do and send a :reply back to the client. If no reply is needed for certain events, you can return a {:noreply, state} tuple instead.

In the reply, we send a new LiveState.Event and update the state to include the newly created to-do.

When you run the example again, you will notice that you can now add new to-dos using the form, and they will appear on the list.

You can add more events to toggle to-dos, but we won't go into those in this post. If you're interested, check out the full code sample.

Bundle Embed Script

If you have been following closely, you'll notice that we integrated a component directly into a Phoenix app. In a typical Phoenix app, there's usually no need to do this if the component is simple enough, as you can create views using LiveView.

The purpose of this example was to create a script that someone can embed on any website to get access to the <todo-list> element. Creating the embeddable script from here is straightforward. There are various ways to do this depending on your app's configuration. We'll walk through it using esbuild, which Phoenix configures by default for new apps.

First, create a new entry point for our embeddable component that only imports the TodoListElement:

// app/js/modules/todos/index.ts
import "./TodoListElement";
Enter fullscreen mode Exit fullscreen mode

Next, configure esbuild to generate a new JavaScript file from this entry point. Update config/config.exs:

config :esbuild,
  version: "0.17.11",
  # this is the default entrypoint that phoenix generates
  todo_app: [
    args:
      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ],
  # add a new config that bundles only the modules/todos/index.ts file
  todos: [
    args:
      ~w(js/modules/todos/index.ts --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]
Enter fullscreen mode Exit fullscreen mode

If you want the embeddable JavaScript to be generated during development, update config/dev.exs to add a new watcher:

config :todo_app, TodoAppWeb.Endpoint,
  # other endpoint config
  watchers: [
    # ...
    # the default watched generated by phoenix
    esbuild: {Esbuild, :install_and_run, [:todo_app, ~w(--sourcemap=inline --watch)]},
    # a new watcher that just watches the todo element (matches the `todos` from config.exs)
    esbuild_todos: {Esbuild, :install_and_run, [:todos, ~w(--sourcemap=inline --watch)]}
  ]
Enter fullscreen mode Exit fullscreen mode

Restart your server, and you should see a new JavaScript file generated inside priv/static/assets. If you embed this component on an external website, include the new JS file and add the <todo-element> as a regular HTML element.

Integrating With JS Frameworks

We've seen an example of integrating LiveState with LitElement, but this is not a requirement. You can also use vanilla JavaScript, adding the phx-live-state package to directly dispatch and listen to events on the LiveState instance without using connectElement.

To use React, the author of LiveState provides a use-live-state hook that simplifies the integration process.

Using this hook, you can efficiently manage the state and events of your to-do list within a React component. This makes it easy to synchronize the client-side state with the server in real time.

export function TodoList() {
  const [state, pushEvent] = useLiveState(liveState, {});
  return (
    <ul style="list-style-type: none">
      ${state.todos?.map((todo) => (
        <li key={todo.id}>
          <input type="checkbox" id={`todo-${todo.id}`} checked={todo.completed}>
          <label for={`todo-${todo.id}`}>{todo.title}</label>
        </li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

And that's it for now!

Wrapping Up

LiveState brings the simplicity and power of LiveView to embeddable web apps and highly interactive client components.

By maintaining server state, LiveState ensures centralized state management, real-time updates, reduced client complexity, and enhanced security.

It is especially useful if parts of your Elixir application need to be embedded in other websites or require rich interactivity with JavaScript frameworks.

Happy coding!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Top comments (0)