Hanso Group

Phoenix LiveView Best Practices

Julian Lindner 17 minutes read

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:

  1. Server-rendered HTML with real-time updates: LiveView sends HTML over the wire rather than JSON, with efficient diffs for updates.

  2. Stateful processes: Each LiveView is a stateful Elixir process, maintaining UI state on the server.

  3. Bidirectional communication: Events flow seamlessly between client and server over WebSocket connections.

  4. 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

  1. Phoenix LiveView Documentation. https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html

  2. Pragmatic Studio’s LiveView Course. https://pragmaticstudio.com/phoenix-liveview

  3. Phoenix Framework Documentation. https://hexdocs.pm/phoenix/Phoenix.html

  4. McCord, C. (2020). Real-Time Phoenix: Build Highly Scalable Systems with Channels. The Pragmatic Bookshelf.

  5. Pragmatic Studio (2022). Phoenix LiveView Course. https://pragmaticstudio.com/phoenix-liveview

Back to all articles