Phoenix LiveView has transformed how we build interactive web applications in Elixir. By enabling rich, real-time user experiences without writing JavaScript, LiveView offers a compelling alternative to traditional single-page application (SPA) frameworks. However, to fully leverage its capabilities whilst maintaining performant, maintainable codebases, we need to follow certain patterns and practices. In this article, we’ll explore best practices for building sophisticated applications with Phoenix LiveView.
Understanding LiveView’s Core Principles
Before diving into specific practices, it’s important to understand the fundamental principles that make LiveView effective:
-
Server-rendered HTML with real-time updates: LiveView sends HTML over the wire rather than JSON, with efficient diffs for updates.
-
Stateful processes: Each LiveView is a stateful Elixir process, maintaining UI state on the server.
-
Bidirectional communication: Events flow seamlessly between client and server over WebSocket connections.
-
Minimal JavaScript: LiveView handles DOM updates automatically, requiring JavaScript only for specialized browser interactions.
These principles influence how we structure our LiveView applications to achieve optimal performance and maintainability.
Organizing LiveViews Effectively
Composing with Live Components
Live components are crucial for building maintainable LiveView applications. They promote code reuse and help manage complexity by encapsulating functionality.
Here’s an example of a well-structured live component for a reusable form:
defmodule MyAppWeb.Components.UserForm do
use MyAppWeb, :live_component
alias MyApp.Accounts
alias MyApp.Accounts.User
@impl true
def update(%{user: user} = assigns, socket) do
changeset = Accounts.change_user(user)
{:ok,
socket
|> assign(assigns)
|> assign(:changeset, changeset)}
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
changeset =
socket.assigns.user
|> Accounts.change_user(user_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
save_user(socket, socket.assigns.action, user_params)
end
defp save_user(socket, :edit, user_params) do
case Accounts.update_user(socket.assigns.user, user_params) do
{:ok, user} ->
{:noreply,
socket
|> put_flash(:info, "User updated successfully")
|> push_navigate(to: ~p"/users/#{user}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
defp save_user(socket, :new, user_params) do
case Accounts.create_user(user_params) do
{:ok, user} ->
{:noreply,
socket
|> put_flash(:info, "User created successfully")
|> push_navigate(to: ~p"/users/#{user}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
@impl true
def render(assigns) do
~H"""
<div>
<.header>
<%= @title %>
<:subtitle>Use this form to manage user records in your database.</:subtitle>
</.header>
<.simple_form
for={@changeset}
id="user-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input field={@changeset[:email]} type="email" label="Email" />
<.input field={@changeset[:name]} type="text" label="Name" />
<:actions>
<.button phx-disable-with="Saving...">Save User</.button>
</:actions>
</.simple_form>
</div>
"""
end
end
To use this component in a parent LiveView:
<.live_component
module={MyAppWeb.Components.UserForm}
id="new-user"
title="New User"
action={:new}
user={%User{}}
/>
Functional Components for Static UIs
For UI elements that don’t need to maintain state or handle events, functional components offer a lighter-weight alternative:
defmodule MyAppWeb.Components.Card do
use Phoenix.Component
attr :title, :string, required: true
attr :description, :string, default: nil
attr :class, :string, default: nil
slot :inner_block, required: false
slot :footer, required: false
def card(assigns) do
~H"""
<div class={["bg-white rounded-lg shadow p-6", @class]}>
<div class="text-xl font-bold mb-4"><%= @title %></div>
<%= if @description do %>
<div class="text-gray-600 mb-4"><%= @description %></div>
<% end %>
<%= if @inner_block do %>
<div class="mb-4"><%= render_slot(@inner_block) %></div>
<% end %>
<%= if @footer do %>
<div class="border-t pt-4 mt-4"><%= render_slot(@footer) %></div>
<% end %>
</div>
"""
end
end
Usage:
<.card title="Important Information" description="This is a description">
<p>Here is some content for the card.</p>
<:footer>
<.button>Action</.button>
</:footer>
</.card>
Function Components vs. Live Components
A common question is when to use function components versus live components. Here’s a decision framework:
Use function components when:
- The UI is primarily presentational
- No local state is needed
- No event handling specific to the component is required
- The component doesn’t need its own lifecycle
Use live components when:
- The component needs to maintain its own state
- The component handles its own events
- You need lifecycle callbacks (mount, update, etc.)
- You want to limit updates to just this component
State Management Best Practices
Minimizing State
One common mistake in LiveView applications is keeping too much state in the socket. This can lead to performance issues and make the application harder to reason about.
Instead of this:
def mount(_params, _session, socket) do
users = Accounts.list_users()
products = Products.list_products()
orders = Orders.list_orders()
{:ok, assign(socket, users: users, products: products, orders: orders)}
end
Do this:
def mount(_params, _session, socket) do
{:ok, socket}
end
def handle_params(_params, _uri, socket) do
users = Accounts.list_users()
{:noreply, assign(socket, users: users)}
end
def handle_event("load_products", _, socket) do
products = Products.list_products()
{:noreply, assign(socket, products: products)}
end
Using assign_new for Lazy Loading
The assign_new/3
function is perfect for lazy loading data that might not be needed:
def mount(_params, _session, socket) do
{:ok,
socket
|> assign_new(:current_user_roles, fn ->
if socket.assigns.current_user do
Accounts.get_user_roles(socket.assigns.current_user)
else
[]
end
end)}
end
Managing Complex State with Structs
For LiveViews with complex state, consider using a struct to organize it:
defmodule MyAppWeb.DashboardLive.State do
defstruct [:user, :stats, :filters, :selected_view, :data]
end
defmodule MyAppWeb.DashboardLive do
# ...
def mount(_params, _session, socket) do
state = %State{
filters: %{date_range: :last_week, status: :all},
selected_view: :table
}
{:ok, assign(socket, :state, state)}
end
def handle_event("update_filters", %{"filters" => filters}, socket) do
state = %{socket.assigns.state | filters: process_filters(filters)}
{:noreply, assign(socket, :state, state)}
end
end
Performance Optimization
Strategic Data Loading
Load data only when needed:
def mount(_params, _session, socket) do
{:ok, socket, temporary_assigns: [events: []]}
end
def handle_params(%{"date" => date}, _uri, socket) do
events = Calendar.get_events_for_date(date)
{:noreply, assign(socket, events: events)}
end
Using temporary_assigns for Large Lists
For LiveViews that manage large lists of items (like an infinite scroll), use temporary_assigns
to tell LiveView to discard DOM nodes after they’re rendered:
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:messages, [])
|> assign(:page, 1),
temporary_assigns: [messages: []]}
end
def handle_event("load-more", _, socket) do
page = socket.assigns.page + 1
messages = Chat.get_messages(page: page, per_page: 20)
{:noreply, socket |> assign(:page, page) |> assign(:messages, messages)}
end
Then in your template:
<div id="messages" phx-update="append">
<%= for message <- @messages do %>
<div id={"message-#{message.id}"}>
<span><%= message.user.name %>: <%= message.content %></span>
</div>
<% end %>
</div>
<div id="load-more" phx-hook="InfiniteScroll"></div>
Debouncing and Throttling Events
For input fields that trigger server events, use phx-debounce
to reduce the number of events:
<input type="text" phx-keyup="search" phx-debounce="300" />
Pagination and Limiting Data
Always paginate large datasets:
def handle_params(%{"page" => page}, _uri, socket) do
page = String.to_integer(page)
pagination = %{page: page, per_page: 20}
users = Accounts.list_users(pagination)
{:noreply, assign(socket, users: users, pagination: pagination)}
end
LiveView Hooks for DOM Interactions
When you need client-side interactivity, use LiveView hooks instead of adding separate JavaScript:
// assets/js/app.js
let Hooks = {}
Hooks.Chart = {
mounted() {
const ctx = this.el.getContext('2d');
this.chart = new Chart(ctx, {
type: 'line',
data: JSON.parse(this.el.dataset.chartData)
});
},
updated() {
this.chart.data = JSON.parse(this.el.dataset.chartData);
this.chart.update();
}
}
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: Hooks
})
In your LiveView template:
<canvas phx-hook="Chart" id="my-chart" data-chart-data={Jason.encode!(@chart_data)}></canvas>
Testing LiveView Applications
Unit Testing Components
For function components, test them independently:
defmodule MyAppWeb.Components.CardTest do
use ExUnit.Case, async: true
import Phoenix.Component
import Phoenix.LiveViewTest
alias MyAppWeb.Components.Card
test "renders a card with title" do
html =
rendered_to_string(~H"""
<Card.card title="Test Title" />
""")
assert html =~ "Test Title"
end
test "renders a card with description" do
html =
rendered_to_string(~H"""
<Card.card title="Test Title" description="Test Description" />
""")
assert html =~ "Test Description"
end
test "renders inner block" do
html =
rendered_to_string(~H"""
<Card.card title="Test Title">
<p>Inner content</p>
</Card.card>
""")
assert html =~ "Inner content"
end
end
Testing LiveViews
For LiveViews, test the complete lifecycle:
defmodule MyAppWeb.UserLiveTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "displays user list", %{conn: conn} do
user = insert(:user)
{:ok, view, _html} = live(conn, ~p"/users")
assert has_element?(view, "#users")
assert has_element?(view, "#user-#{user.id}")
end
test "creates a user", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/new")
view
|> form("#user-form", user: %{name: "Jane Doe", email: "jane@example.com"})
|> render_submit()
assert_redirected(view, ~p"/users")
{:ok, view, _html} = live(conn, ~p"/users")
assert has_element?(view, "td", "Jane Doe")
end
end
Testing Live Components
For live components, test them in context of a parent LiveView:
defmodule MyAppWeb.Components.UserFormTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "validates user input", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/new")
view
|> form("#user-form", user: %{email: "invalid"})
|> render_change()
assert has_element?(view, "p", "must have the @ sign and no spaces")
end
end
Advanced LiveView Patterns
Handling PubSub for Real-time Updates
LiveView works well with Phoenix PubSub for broadcasting updates to multiple clients:
defmodule MyAppWeb.ChatLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "chat:lobby")
end
messages = Chat.list_messages()
{:ok, assign(socket, messages: messages, new_message: "")}
end
def handle_event("send_message", %{"message" => content}, socket) do
user = socket.assigns.current_user
{:ok, message} = Chat.create_message(%{
content: content,
user_id: user.id
})
Phoenix.PubSub.broadcast(MyApp.PubSub, "chat:lobby", {:new_message, message})
{:noreply, assign(socket, new_message: "")}
end
def handle_info({:new_message, message}, socket) do
{:noreply, update(socket, :messages, fn messages -> [message | messages] end)}
end
end
Using LiveView Uploads
For file uploads, use LiveView’s built-in upload handling:
def mount(_params, _session, socket) do
{:ok,
socket
|> allow_upload(:avatar,
accept: ~w(.jpg .jpeg .png),
max_entries: 1,
max_file_size: 5_000_000
)}
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset =
%User{}
|> User.changeset(user_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
def handle_event("save", %{"user" => user_params}, socket) do
uploaded_files =
consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry ->
dest = Path.join(["priv", "static", "uploads", Path.basename(path)])
File.cp!(path, dest)
Routes.static_path(socket, "/uploads/#{Path.basename(dest)}")
end)
user_params = Map.put(user_params, "avatar_url", List.first(uploaded_files))
case Accounts.create_user(user_params) do
{:ok, user} ->
{:noreply,
socket
|> put_flash(:info, "User created successfully")
|> push_navigate(to: ~p"/users/#{user}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
In your template:
<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save">
<.live_file_input upload={@uploads.avatar} />
<%= for entry <- @uploads.avatar.entries do %>
<div class="upload-entry">
<.live_img_preview entry={entry} />
<%= for err <- upload_errors(@uploads.avatar, entry) do %>
<div class="error"><%= err %></div>
<% end %>
</div>
<% end %>
<.input field={f[:name]} type="text" label="Name" />
<.input field={f[:email]} type="email" label="Email" />
<.button>Save</.button>
</.form>
Implementing LiveView Navigation
For smoother navigation between LiveViews, use the navigation helpers:
def handle_event("navigate", %{"to" => path}, socket) do
{:noreply, push_navigate(socket, to: path)}
end
Or for navigating while maintaining browser history:
<.link navigate={~p"/users/#{user}"}>View User</.link>
LiveView and JSON API Coexistence
You can serve both LiveView HTML and JSON responses from the same controllers using Phoenix.Controller.get_format/1
:
def show(conn, %{"id" => id}) do
user = Accounts.get_user!(id)
case get_format(conn) do
"html" ->
render(conn, :show, user: user)
"json" ->
render(conn, :show, data: %{user: user})
end
end
Conclusion
Phoenix LiveView enables developers to build reactive, real-time web applications with the productivity and reliability of Elixir. By following the best practices outlined in this article, you can create LiveView applications that are performant, maintainable, and take full advantage of LiveView’s unique architecture.
Remember that LiveView excels when you embrace its server-first paradigm rather than trying to recreate client-heavy SPA patterns. By thoughtfully structuring your components, carefully managing state, and implementing appropriate optimizations, you can deliver excellent user experiences while maintaining the simplicity and robustness that make Elixir applications a joy to build and maintain.
As LiveView continues to evolve, stay current with emerging patterns and features by regularly reviewing the official documentation and participating in the community. The most successful LiveView applications are those that balance innovation with established best practices.
References
-
Phoenix LiveView Documentation. https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
-
Pragmatic Studio’s LiveView Course. https://pragmaticstudio.com/phoenix-liveview
-
Phoenix Framework Documentation. https://hexdocs.pm/phoenix/Phoenix.html
-
McCord, C. (2020). Real-Time Phoenix: Build Highly Scalable Systems with Channels. The Pragmatic Bookshelf.
-
Pragmatic Studio (2022). Phoenix LiveView Course. https://pragmaticstudio.com/phoenix-liveview