Introduction to Phoenix LiveView

Phoenix LiveView is a library that allows to write interactive, real-time user interfaces with server-rendered HTML without using JavaScript. LiveView shares view functionality with standard server side rendered HTML. The difference is that LiveViews views are stateful with support of bidirectional communication between client and server. This allows to push updates to the client and reacts to client’s events. Server events are also supported.

Advantages

  • Build on top of Elixir and Phoenix Channels ensures great performance and scalability. Elixir (or more specifically Erlang and its virtual machine that Elixir runs on) is a language used for building massively scalable soft real-time systems with requirements on high availability. It is more than capable of handling hundreds of thousands or even millions of connections;
  • offers a simpler programming model than JS frameworks backed by APIs. There is no frontend framework or explicit communication between server and client which reduces complexity. LiveView templates are the same as standard server-rendered HTML templates;
  • LiveView is first rendered statically as part of HTTP response, which provides quick time to “First Meaningful Paint” and plays nicely with search engines. Succeeding communication is over WebSocket connection;
  • changes in HTML are tracked by LiveView and only difference is send to the client instead of whole HTML. This results in smaller size of the data transfered than typical hand-crafted JSON. In future LiveView will offer External Term Format – a binary encoding format used by Erlang/Elixir – resulting with even smaller payloads at the cost of decoding the data in browser;
  • ligther than JS frameworks – only 29KB minified, compared to Vue.js 88KB and React + ReactDOM 112KB (that doesn’t include additional JS libraries like router, state management etc).

Disadvantages

  • it can’t replace every frontend application. For full blown applications with desktop like features you are better with JS framework;
  • because it requires constant WebSocket connection to the server it doesn’t support offline applications;
  • there is a latency caused by communication with server.

So where does LiveView shine? When you need a bit of interactivity such as real-time autocomplete, validation, dashboards, data tables, multi-step forms or simple games.

For more information and examples you can check introduction to LiveView by Chris McCord, author of Phoenix Framework and LiveView. You can also read project’s GitHub and documentation.

Setup

In this tutorial we will implement yet another version of TodoMVC. To keep it short some similar features will be omitted. Here are few commands to get you started.

git clone git@github.com:alukasz/todo_live_view.git && cd todo_live_view
git checkout part-one
mix deps.get && mix deps.compile
yarn install --cwd assets # or cd assets && npm install
mix phx.server

App is available at http://localhost:4000.

Displaying todos

The first thing we do is to define struct describing todo.

# lib/todo.ex
defmodule Todo do
  defstruct [:id, :title, completed: false]

  def create(%{"title" => title}) when byte_size(title) >= 3 do
    {:ok, %Todo{id: Ecto.UUID.generate(), title: title}}
  end

  def create(_) do
    {:error, :invalid_title}
  end
end

Now it’s time for LiveView module. It must implement 2 callbacks:

  • render/1 – renders HTML template. HTML can be returned directly using ~L sigil or rendered using Phoenix’s view layer;
  • mount/2 – invoked during initial template rendering and succeding WebSocket connection. Here default data for template is assigned to the socket and will be passed to the template (the assigns argument in render/1 function).
# lib/todo_web/live/todo_live.ex
defmodule TodoWeb.TodoLive do
  use Phoenix.LiveView

  @todos [
    %Todo{id: "1", title: "Taste Elixir & Phoenix LiveView", completed: true},
    %Todo{id: "2", title: "Buy a unicorn", completed: false}
  ]

  def render(assigns) do
    TodoWeb.TodoView.render("index.html", assigns)
  end

  def mount(_, socket) do
    {:ok, assign(socket, :todos, @todos)}
  end
end

Example todos are assigned to the socket in mount/2 using assign/3 function. Last part is the view and template, which we invoked in render/1.

# lib/todo_web/views/todo_view.ex
defmodule TodoWeb.TodoView do
  use TodoWeb, :view
end

# lib/todo_web/templates/todo/index.html.leex
  <ul class="todo-list">
    <%= for todo <- @todos do %>
      <li <%= if todo.completed, do: "class=completed" %>>
        <div class="view">
          <input class="toggle" type="checkbox">
          <label><%= todo.title %></label>
        </div>
      </li>
    <% end %>
  </ul>

Start Phoenix server with mix phx.server and head to http://localhost:4000.

Creating todos

So far there isn’t any real-time interactivity and it’s time to change that. For creating todos we will use phx-submit binding. We add it to the form tag in the template. When submitting form its content will be send to the LiveView.

# lib/todo_web/templates/todo/index.html.leex

  <header class="header">
    <h1>todos</h1>
    <form phx-submit="add_todo">
      <input class="new-todo" name="todo[title]" placeholder="What needs to be done?" autofocus>
    </form>
  </header>

Bindings invoke a handle_event/3 callback in LiveView module, with first argument being event name and second being additional parameters. We can use this data to create a todo and appended it to the list of todos.

# lib/todo_web/live/todo_live.ex
  def handle_event("add_todo", %{"todo" => todo_params}, socket) do
    case Todo.create(todo_params) do
      {:ok, todo} ->
        {:noreply, assign(socket, :todos, socket.assigns.todos ++ [todo])}
      error ->
        {:noreply, socket}
    end
  end

Here is how this works:

Besides phx-submit LiveView supports many other bindings.

Toggling todos

To mark todos as completed or not we will use phx-click with phx-value-* bindings.

# lib/todo_web/templates/todo/index.html.leex
  <div class="view">
    <input class="toggle" type="checkbox"
            phx-click="toggle_todo"
            phx-value-id="<%= todo.id %>"
            <%= if todo.completed, do: "checked" %>>
    <label><%= todo.title %></label>
  </div>

On the backend we implement handle_event/3 for "toggle_todo" event. Using todo_id passed from phx-value-id we switch the completed field of todo. Deleting todo is left as an exercise for the reader.

# lib/todo_web/live/todo_live.ex
  def handle_event("toggle_todo", %{"id" => todo_id}, socket) do
    todos = toggle_todo(socket.assigns.todos, todo_id)
    {:noreply, assign(socket, :todos, todos)}
  end

  defp toggle_todo(todos, todo_id) do
    Enum.map(todos, fn
      %Todo{id: ^todo_id, completed: completed} = todo ->
        %{todo | completed: !completed}

      todo ->
        todo
    end)
  end

Live links and filtering todos

We have added a nice amount of interactivity to the application. Let’s go a step further and add a SPA-like routing to filter todos. It is possible using live_link/3 and live_redirect/2 functions provided by LiveView.

First we define a second route that will match against different filters in the URL.

# lib/todo_web/router.ex
  live "/", TodoLive, as: :todo
  live "/:filter", TodoLive, as: :todo

Because both routes point to the same LiveView, the library won’t mount a new LiveView every time the link is clicked. Instead a handle_params/3 callback will be invoked. It will receive route parameters as first argument and url as second. We can use this to display only selected todos. Two functions handle_params/3 are defined. The first one will match only when status is one of active or completed. The second one is a fallback that will set filter to all. In mount/2 a default filter is set.

# lib/todo_web/live/todo_live.ex
  def mount(_, socket) do
    {:ok, assign(socket, todos: @todos, filter: "all")}
  end

  def handle_params(%{"filter" => filter}, uri, socket) 
      when filter in ["active", "completed"] do
    {:noreply, assign(socket, :filter, filter)}
  end

  def handle_params(params, uri, socket) do
    {:noreply, assign(socket, :filter, "all")}
  end

Then we need to make changes in the template. We will add 2 functions filter_todos/2 and filter_link/3 in TodoView to help us generating HTML.

# lib/todo_web/views/todo_view.ex
  def filter_todos(todos, "all"), do: todos
  def filter_todos(todos, "active"), do: Enum.reject(todos, &(&1.completed))
  def filter_todos(todos, "completed"), do: Enum.filter(todos, &(&1.completed))

  def filter_link(socket, filter, active_filter) do
    route = Routes.todo_path(socket, TodoWeb.TodoLive, filter)
    live_link(String.capitalize(filter), to: route,
      class: filter_class(filter, active_filter))
  end

  defp filter_class(filter, filter), do: "selected"
  defp filter_class(_, _), do: nil
# lib/todo_web/templates/todo/index.html.leex
  <ul class="todo-list">
    <%= for todo <- filter_todos(@todos, @filter) do %>
      ...
    <% end %>
  </ul>
  ...
  <ul class="filters">
    <%= for filter <- ["all", "active", "completed"] do %>
      <li>
        <%= filter_link(@socket, filter, @filter) %>
      </li>
    <% end %>
  </ul>

Here is the final result in action:

Look at the payloads

Let’s check communication between LiveView and browser. First the initial HTTP response contains todos rendered as plain HTML.

Then, any change in the todos will result in sending data over the WebSocket. When marking todo as complete the following payload is received. Under dynamics key there is a list of HTML elements that changed. There is small overhead of Phoenix Channels and LiveView itself, but you can see the HTML changes in payload.

[
  "1",
  "9",
  "lv:phx-5NBVjiDG",
  "phx_reply",
  {
    "response": {
      "diff": {
        "0": {
          "dynamics": [
            [
              "class=completed",
              "c79837da-81ec-4f9e-9e5c-5e8e380dda29",
              "checked",
              "Taste Elixir & Phoenix LiveView"
            ],
            [
              "",
              "584216d7-4441-4e88-bbe9-7a5a6e43ffaa",
              "",
              "Buy a unicorn"
            ]
          ]
        }
      }
    },
    "status": "ok"
  }
]

Summary

You can find finished application https://github.com/alukasz/todo_live_view/tree/part-two

In part two (soon!) we will dive into OTP to store todos.

Łukasz Antończyk
About

Software developer. Elixir enthusiast. After hours watches rocket launches.