LiveView Patterns for Complex UIs
Component design, nested live views, and handling complex state in LiveView
LiveView's marketing writes checks that naive implementations can't cash. The demos show real-time updates, forms that validate as you type, interactive dashboards--all without writing JavaScript. What they don't show is what happens when your application grows past a single LiveView with a handful of assigns.
I've watched teams hit this wall. The initial velocity is intoxicating; you're shipping features faster than you ever did with a React SPA, and it feels like cheating. Then the LiveView balloons to 800 lines. State becomes a tangled graph of interdependencies. Every change risks breaking three unrelated features. The team concludes that LiveView "doesn't scale" and reaches for React.
They're wrong. LiveView scales fine. But it demands architectural discipline that the simple examples never teach you--and those examples are what most teams copy-paste from when they start.
The Complexity Threshold
LiveView changed significantly in version 0.18.liveview-018-overhaul Function components replaced the old live_component for stateless rendering; the HEEx engine brought compile-time validation. These weren't cosmetic changes--they fundamentally altered how you should structure complex UIs.
The patterns that work for a CRUD interface collapse under real weight: drag-and-drop kanban boards, collaborative editors, multi-step wizards with branching logic, dashboards with dozens of independently updating widgets. Each of these demands deliberate choices about component boundaries, state ownership, and communication patterns. Get the boundaries wrong and you're fighting the framework instead of using it.
Stateless vs Stateful: Pick a Side
The first decision is simple but consequential. Does this component need its own state?
Function components are pure. They receive assigns, return markup, have no lifecycle. Fast, predictable, and should be your default.
# A stateless function component — pure rendering
attr :user, :map, required: true
attr :show_email, :boolean, default: false
def user_card(assigns) do
~H"""
<div class="user-card">
<img src={@user.avatar_url} alt={@user.name} />
<h3><%= @user.name %></h3>
<%= if @show_email do %>
<p class="email"><%= @user.email %></p>
<% end %>
</div>
"""
end
Use function components when the parent owns all the relevant state. The component formats and displays. Nothing more.
Live components are stateful but not independent processes.live-component-process-model They carry their own assigns, their own lifecycle callbacks; they can handle events without involving the parent. That power has a coordination cost.
# A stateful live component — encapsulated behavior
defmodule MyAppWeb.CounterComponent do
use MyAppWeb, :live_component
def mount(socket) do
{:ok, assign(socket, count: 0)}
end
def handle_event("increment", _, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
def render(assigns) do
~H"""
<div>
<span>Count: <%= @count %></span>
<button phx-click="increment" phx-target={@myself}>+</button>
</div>
"""
end
end
The phx-target={@myself} matters more than it looks.phx-target-bubbling Without it, the event bubbles to the parent LiveView. This is the encapsulation boundary--the component handles its own events, or the parent does. No middle ground.
Reach for live components when the component has internal state the parent shouldn't manage, when you need to isolate re-renders to a DOM subtree, or when events are purely internal to the component's function. Don't reach for them just because a piece of UI feels "component-like." That instinct comes from React.react-mental-model In LiveView, the cost-benefit calculus is different; every live component adds coordination overhead that a function component avoids entirely.
Component Communication Patterns
Components in isolation are useless. They need to talk.
Parent to Child: send_update
When a parent needs to push data to a live component, send_update/3 is the mechanism.
# In the parent LiveView
def handle_info({:user_updated, user}, socket) do
send_update(UserProfileComponent, id: "user-profile", user: user)
{:noreply, socket}
end
The component receives this in its update/2 callback:
# In the live component
def update(%{user: user} = assigns, socket) do
{:ok, assign(socket, user: user, loading: false)}
end
Targeted. You're sending data to a specific component instance by ID; the component decides what to do with it.
Child to Parent: Events and Messages
Children communicate upward through events or explicit message passing.
# Child component sends a message to parent
def handle_event("save", params, socket) do
case save_item(params) do
{:ok, item} ->
send(self(), {:item_saved, item})
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
The parent handles this in handle_info/2:
def handle_info({:item_saved, item}, socket) do
{:noreply, stream_insert(socket, :items, item)}
end
Note the send(self(), ...) pattern. Because the live component runs in the parent's process, self() returns the parent's PID. This isn't a hack; it's the intended design. The message lands in the parent's handle_info/2 on the next iteration of its receive loop.
Sibling Communication: PubSub
When components have no direct relationship--or when you want genuinely decoupled communication--Phoenix.PubSub is the right tool.
# Component A broadcasts
def handle_event("select_project", %{"id" => id}, socket) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "project:selected", {:project_selected, id})
{:noreply, socket}
end
# Component B subscribes and listens
def mount(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "project:selected")
{:ok, socket}
end
def handle_info({:project_selected, project_id}, socket) do
{:noreply, assign(socket, current_project_id: project_id)}
end
PubSub shines when multiple unrelated components need to react to the same event. It also works across LiveView instances--and across nodes in a cluster--which matters for collaborative features.pubsub-distributed-erlang
Nested LiveViews vs Components
A nested LiveView is a separate Elixir process. A live component runs in the parent's process. This distinction matters more than the docs suggest.
Nested LiveViews give you process isolation--a crash in the child doesn't crash the parent. They give you independent lifecycle; the child mounts, connects, and disconnects on its own schedule. They give you memory isolation, so heavy state in the child doesn't bloat the parent's heap.
The costs are real. Message passing between processes is slower than function calls within one; managing the parent-child relationship requires care; keeping state consistent across process boundaries is your problem, not the framework's.
# A live component (same process as parent)
<.live_component module={MyAppWeb.SidebarComponent} id="sidebar" />
# A nested LiveView (separate process)
<%= live_render(@socket, MyAppWeb.ChatLive, id: "chat-window", session: %{"room_id" => @room_id}) %>
Use nested LiveViews when the feature is genuinely independent--a chat widget, an embedded editor, a live metrics panel. Use them when you need fault isolation, or when the nested view carries heavy state that would bloat the parent. Use live components for everything else.
Async Assigns for Long-Running Operations
Blocking in mount/3 or handle_event/3 is the fastest way to make your UI feel broken. A database query takes 200ms; your user stares at nothing for 200ms. That's not acceptable.
assign_async/3 solves this.assign-async-version It loads data in a spawned task while the LiveView renders immediately with a loading state.
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Dashboard")
|> assign_async(:stats, fn -> fetch_dashboard_stats() end)
|> assign_async(:recent_activity, fn -> fetch_recent_activity() end)}
end
defp fetch_dashboard_stats do
# Expensive aggregation query
{:ok, %{users: count_users(), revenue: calculate_revenue()}}
end
In your template, handle loading and error states:
<.async_result :let={stats} assign={@stats}>
<:loading>
<.spinner />
</:loading>
<:failed :let={_reason}>
<p>Failed to load statistics</p>
</:failed>
<div class="stats-grid">
<.stat_card label="Users" value={stats.users} />
<.stat_card label="Revenue" value={stats.revenue} />
</div>
</.async_result>
The UI renders immediately; async data streams in as it becomes available. This isn't a workaround. It's the correct pattern for any operation that might be slow.
For operations triggered by user actions, start_async/3 follows the same idea:
def handle_event("generate_report", %{"type" => type}, socket) do
{:noreply, start_async(socket, :report, fn -> generate_report(type) end)}
end
def handle_async(:report, {:ok, report}, socket) do
{:noreply, assign(socket, report: report, generating: false)}
end
def handle_async(:report, {:exit, reason}, socket) do
{:noreply, assign(socket, error: "Report generation failed", generating: false)}
end
Optimistic UI Updates
Users don't care about your database transaction. They care about perceived responsiveness.
Optimistic UI updates the interface before the server confirms the action.optimistic-ui-tradeoffs If the server rejects it, you roll back. The user sees instant feedback; the server catches up in the background.
def handle_event("toggle_complete", %{"id" => id}, socket) do
task = get_task(socket.assigns.tasks, id)
# Update the UI immediately
updated_task = %{task | completed: !task.completed}
socket = stream_insert(socket, :tasks, updated_task)
# Persist asynchronously
{:noreply, start_async(socket, {:save_task, id}, fn ->
Tasks.toggle_complete(id)
end)}
end
def handle_async({:save_task, id}, {:ok, _task}, socket) do
{:noreply, socket} # Already updated optimistically
end
def handle_async({:save_task, id}, {:exit, _reason}, socket) do
# Roll back the optimistic update
task = Tasks.get_task!(id)
{:noreply, stream_insert(socket, :tasks, task)}
end
For simpler cases, LiveView's built-in loading states often suffice:
<button phx-click="submit" phx-disable-with="Saving...">
Save
</button>
The JS module provides finer control when you need it:
def show_saving_indicator(js \\ %JS{}) do
js
|> JS.hide(to: "#save-button")
|> JS.show(to: "#saving-spinner")
|> JS.push("save")
end
Form Handling Patterns
Forms are where LiveView's design shines--and where complexity accumulates fastest.
The Form Abstraction
Always use to_form/1 to wrap your changesets.to-form-history It provides a consistent interface; it handles edge cases you don't want to think about.
def mount(_params, _session, socket) do
changeset = Articles.change_article(%Article{})
{:ok, assign(socket, form: to_form(changeset))}
end
def handle_event("validate", %{"article" => params}, socket) do
changeset =
%Article{}
|> Articles.change_article(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
Nested Associations
For forms with nested data, inputs_for/1 handles the wiring:
<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:title]} label="Title" />
<.inputs_for :let={tag_form} field={@form[:tags]}>
<.input field={tag_form[:name]} label="Tag" />
<button type="button" phx-click="remove_tag" phx-value-index={tag_form.index}>
Remove
</button>
</.inputs_for>
<button type="button" phx-click="add_tag">Add Tag</button>
<button type="submit">Save</button>
</.form>
The dynamic add/remove in your LiveView:
def handle_event("add_tag", _, socket) do
changeset = socket.assigns.form.source
tags = Ecto.Changeset.get_field(changeset, :tags) || []
changeset = Ecto.Changeset.put_assoc(changeset, :tags, tags ++ [%Tag{}])
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("remove_tag", %{"index" => index}, socket) do
changeset = socket.assigns.form.source
tags = Ecto.Changeset.get_field(changeset, :tags) || []
tags = List.delete_at(tags, String.to_integer(index))
changeset = Ecto.Changeset.put_assoc(changeset, :tags, tags)
{:noreply, assign(socket, form: to_form(changeset))}
end
JS Hooks Integration
Sometimes LiveView's DOM patching isn't enough. Rich text editors, charts, drag-and-drop, third-party widget libraries--these need direct DOM access that LiveView can't provide through its diffing engine.
// assets/js/hooks.js
let Hooks = {}
Hooks.Chart = {
mounted() {
this.chart = new Chart(this.el, {
type: 'line',
data: JSON.parse(this.el.dataset.chartData)
})
this.handleEvent("update_chart", ({data}) => {
this.chart.data = data
this.chart.update()
})
},
updated() {
// Called when LiveView updates the element
const newData = JSON.parse(this.el.dataset.chartData)
this.chart.data = newData
this.chart.update()
},
destroyed() {
this.chart.destroy()
}
}
export default Hooks
Wire it up in your template:
<canvas
id="revenue-chart"
phx-hook="Chart"
data-chart-data={Jason.encode!(@chart_data)}
/>
Push updates from the server:
def handle_info({:new_data_point, point}, socket) do
chart_data = update_chart_data(socket.assigns.chart_data, point)
{:noreply,
socket
|> assign(:chart_data, chart_data)
|> push_event("update_chart", %{data: chart_data})}
end
One thing that bites people: LiveView will try to patch the DOM that your JavaScript hook is managing.phx-update-ignore You need to tell it not to. The phx-update="ignore" attribute on the hook's container element prevents LiveView from clobbering whatever your JS code has done to the DOM. Forget this and you'll spend hours debugging why your chart keeps resetting.
The Composition Principle
These patterns aren't alternatives. They compose.
A real dashboard might use function components for layout and presentation; live components for widgets with internal state; async assigns to load widget data without blocking; PubSub to synchronize widgets when shared state changes; JS hooks for the charting library. Each pattern handles one concern. The architecture emerges from how they fit together.
The skill isn't learning the patterns--it's learning when each one earns its complexity. A function component that could be a live component is a missed simplification. A live component that should be a nested LiveView is a crash waiting to happen.
LiveView applications fail when developers treat the framework as magic. They succeed when developers treat it as a toolkit with clear tradeoffs and choose deliberately. The demos weren't lying. LiveView can build complex, responsive, real-time UIs with minimal JavaScript. But it can't do it for you. You still have to think.