A few days ago, I wrote about Elixir and discussed how it's built for concurrency. Phoenix, the most popular web framework for Elixir, leverages these features to provide a great way to build web applications.
For the uninitiated, Phoenix is a full-fledged backend framework. Essentially, what Rails is for Ruby, Phoenix is for Elixir. It offers a clean API design and includes many of the batteries you typically need for a web framework.
This was all well and good until the rise of Single Page Application (SPA) frameworks. SPAs introduced a level of user experience (UX) that was previously unseen: instant feedback, graceful transitions, and advanced UX techniques. However, this required a non-trivial amount of JavaScript. In fact, it was so different that it necessitated its own set of tools and ecosystems to piece everything together.
To meet this new demand, many frameworks have introduced different primitives to enhance the client-side experience while keeping the JavaScript footprint lower.
This is where Phoenix introduces LiveView.
After diving into Elixir and the BEAM (Erlang virtual machine), LiveView feels like such a natural extension of Phoenix. Let's dig into it!
Ephemeral State on the Server
One of the biggest advantages of client-side SPA frameworks is the ability to maintain state on the client. This allows for fast feedback loops because everything happens client-side.
Some examples of this ephemeral state include:
Is the form valid?
Is the modal open?
Is the data loading?
And many more...
We use this state on the client because it’s closest to the user's interactions. SPA frameworks offer reactivity, which lets us instantly respond to these states and provide immediate feedback to users. For example, showing an error message as a user fills out an invalid form, rather than waiting until they submit and get vague feedback.
Finally, when it's relevant, we reach out to the server to synchronize the state with the client.
While I’ve condensed this explanation into a single line, it's actually a complex task. There are gains to be had, but as with everything, it comes with a cost.
LiveView uses Elixir’s process memory on the server to store this state. This reduces the burden of synchronizing state between the client and server.
In this model, the server becomes the single source of truth!
But how does the client still give instant feedback, like an SPA?
Through web sockets!
Typically, opening a web socket connection between the client and server can be expensive. But here, Elixir/BEAM shines. By using lightweight ephemeral processes, Elixir connects to a web socket on the client and communicates through messages.
Because it's a two-way connection and doesn't require the browser to "reload" things, it’s fast.
Example: A Simple Counter
Let’s compare a simple counter app in React and LiveView to see the difference.
React Example:
export default function Counter() {
const [count, setCount] = React.useState(0);
function handleIncrement() {
setCount(prev => prev + 1);
}
function handleDecrement() {
setCount(prev => prev - 1);
}
return (
<div>
<button onClick={handleDecrement}>-</button>
<h1>Count: {count}</h1>
<button onClick={handleIncrement}>+</button>
</div>
);
}
In this React example, you get instant feedback. The counter smoothly reacts to user interactions.
To achieve this behavior in a pure server-side framework would require multiple HTTP requests and reloads. Alternatively, you could use XHR calls and update the DOM, but the UX wouldn’t be as smooth.
LiveView Example:
defmodule BlogSamplesWeb.CounterLive do
use BlogSamplesWeb, :live_view
def mount(_params, _session, socket) do
socket = assign(socket, :count, 0)
{:ok, socket}
end
def render(assigns) do
~H"""
<button phx-click="decrement">-</button>
<h1>Count: {@count}</h1>
<button phx-click="increment">+</button>
"""
end
def handle_event("increment", _, socket) do
{:noreply, assign(socket, :count, socket.assigns.count + 1)}
end
def handle_event("decrement", _, socket) do
{:noreply, assign(socket, :count, socket.assigns.count - 1)}
end
end
Notice how similar the code is to the React example! If you run this code in your terminal, you’ll experience the exact same user interaction. However, it works in a completely different way!
Each time you click the +
or -
button, a message is sent via a web socket to the server. The server responds with the updated state, and the client patches the UI using morphdom.
In this case, the count
state is stored on the server, not the client. But you still get the same user experience!
This video covers the basic flow:
Modeling Data Changes with Ecto Changesets
In Elixir, we use Ecto Changesets to model changes to data. Here's an example of a typical user schema in a web application:
defmodule User do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :name, :string
field :age, :integer
field :email, :string
end
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :age, :email])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
|> validate_number(:age, greater_than: 0)
end
end
Before LiveView, this schema would be rendered in a template, and the form would be submitted to the server for validation. If there were any mistakes, we’d show error messages. This is the typical HTTP flow.
To give quick feedback as the user types, we’d have to rely on client-side logic.
But with LiveView, we don’t need that anymore!
We can send each keystroke to the server. Since the server already has the changeset defined, it can validate the input and send back feedback over the socket.
See how well Phoenix’s existing tools fit together? It also speaks to the great API design in Ecto. The fact that we can use a single facet (the changeset) for real-time validations is pretty cool.
This reduces a lot of client-side logic and maintenance. If the validation rules change, you only need to update the server. There's no need to touch the client or synchronize the two.
We now have a single source of truth! No more synchronization issues for forms.
Processes Everywhere
The BEAM (Elixir VM) is primarily built around lightweight processes, which are conceptually based on the Actor model. In Elixir, each process is an "actor" with its own state. To modify the state, you need to send it a message.
Each LiveView is also a process, which constantly communicates with the browser over a web socket.
We can interact with a LiveView just like any other Elixir process—by sending messages. The LiveView process syncs the state with the client, which makes everything feel seamless.
This concept ties everything together in a beautiful way. Even with my somewhat vague mental model, I can see the elegance of this design.
Sync Clients with PubSub
Another important trend these days is real-time synchronization. The idea is that if something changes in one place, all relevant consumers should see it in real-time.
For example, take an app like Figma, where multiple users can simultaneously make changes, and those changes reflect in other users' browsers in real-time.
This usually requires complex infrastructure and client-side engineering. The inherent complexity discourages many from adding real-time features.
With Phoenix, Elixir, and LiveView, adding real-time synchronization is surprisingly easy! PubSub is a first-class citizen in the Elixir ecosystem.
Since LiveView is just another process, syncing multiple clients becomes as simple as broadcasting and subscribing to messages. I’ve built simple systems where changes were reflected instantly!
Final Words
There’s so much more to explore, and I’m just scratching the surface here. But suffice it to say, LiveView provides an elegant way to build responsive UIs without reaching for advanced JavaScript frameworks.
One of the challenges of using SPA frameworks is synchronizing the state between the client and server. By splitting state between the two, SPAs introduce a layer of complexity to keep everything in sync.
React has introduced primitives like React Server Components (RSC) to bridge this gap, with frameworks like Next.js and React Router leveraging these concepts to reduce the burden. LiveView, on the other hand, takes a different approach by moving more logic to the server, reducing sync problems.
Not only that, but the developer experience (DX) is fantastic. LiveView takes the best features of client-side frameworks and integrates them as built-in features!
All this to say: please give LiveView a shot! 😊