LiveState for Elixir: An Overview and How to Build Embeddable Web Apps
Pulkit Goyal
Posted on September 3, 2024
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
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
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
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
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
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>
`;
}
}
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";
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>
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']
}
});
}
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>
`;
}
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();
}
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
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";
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__)}
]
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)]}
]
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>
);
};
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!
Posted on September 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.