23m read
Tags: elixir, phoenix, liveview, frontend

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.


What do you think of what I said?

Share with me your thoughts. You can tweet me at @allanmacgregor.