O Phoenix LiveView permite experiências de utilizador ricas e em tempo real sem escrever JavaScript. Mantém uma ligação WebSocket persistente entre o browser e o servidor, enviando diffs mínimos para atualizar o DOM. Este guia aborda a criação de aplicações interativas com LiveView na perspetiva de um programador sénior.
Porquê LiveView
O LiveView oferece vantagens únicas:
- Renderizado no servidor: compatível com SEO, sem necessidade de framework JavaScript
- Tempo real: atualizações automáticas via WebSocket
- Transferência mínima de dados: apenas diffs do DOM enviados pela ligação
- Ecossistema Elixir: tirar partido de OTP para escalabilidade
- Estado no servidor: sem a complexidade da gestão de estado no lado do cliente
Configuração
Criar projeto Phoenix com LiveView
mix archive.install hex phx_new
mix phx.new my_app --live
cd my_app
mix ecto.setup
mix phx.server
Adicionar LiveView a um projeto existente
Adicionar a mix.exs:
defp deps do
[
{:phoenix_live_view, "~> 0.19"},
# ...
]
end
Componente LiveView básico
Criar LiveView
lib/myappweb/live/counter_live.ex:
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
@impl true
def render(assigns) do
~H"""
<div class="counter">
<h1>Count: <%= @count %></h1>
<button phx-click="increment">+</button>
<button phx-click="decrement">-</button>
<button phx-click="reset">Reset</button>
</div>
"""
end
@impl true
def handle_event("increment", _params, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
def handle_event("decrement", _params, socket) do
{:noreply, update(socket, :count, &(&1 - 1))}
end
def handle_event("reset", _params, socket) do
{:noreply, assign(socket, count: 0)}
end
end
Adicionar rota
lib/myappweb/router.ex:
scope "/", MyAppWeb do
pipe_through :browser
live "/counter", CounterLive
end
Exemplo de lista de tarefas com PubSub
Módulo de contexto
lib/my_app/todos.ex:
defmodule MyApp.Todos do
import Ecto.Query
alias MyApp.Repo
alias MyApp.Todos.Todo
@topic inspect(__MODULE__)
def subscribe do
Phoenix.PubSub.subscribe(MyApp.PubSub, @topic)
end
defp broadcast_change({:ok, result}, event) do
Phoenix.PubSub.broadcast(MyApp.PubSub, @topic, {__MODULE__, event, result})
{:ok, result}
end
defp broadcast_change({:error, _} = error, _event), do: error
def list_todos do
Repo.all(from t in Todo, order_by: [desc: t.inserted_at])
end
def get_todo!(id), do: Repo.get!(Todo, id)
def create_todo(attrs \\ %{}) do
%Todo{}
|> Todo.changeset(attrs)
|> Repo.insert()
|> broadcast_change([:todo, :created])
end
def update_todo(%Todo{} = todo, attrs) do
todo
|> Todo.changeset(attrs)
|> Repo.update()
|> broadcast_change([:todo, :updated])
end
def delete_todo(%Todo{} = todo) do
todo
|> Repo.delete()
|> broadcast_change([:todo, :deleted])
end
def toggle_todo(%Todo{} = todo) do
update_todo(todo, %{done: !todo.done})
end
end
Componente LiveView
lib/myappweb/live/todo_live.ex:
defmodule MyAppWeb.TodoLive do
use MyAppWeb, :live_view
alias MyApp.Todos
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Todos.subscribe()
end
{:ok, fetch_todos(socket)}
end
@impl true
def render(assigns) do
~H"""
<div class="todo-app">
<h1>Todo List</h1>
<form phx-submit="add" class="add-form">
<input
type="text"
name="title"
placeholder="What needs to be done?"
autofocus
autocomplete="off"
/>
<button type="submit">Add</button>
</form>
<div class="filters">
<button phx-click="filter" phx-value-status="all"
class={if @filter == "all", do: "active"}>
All
</button>
<button phx-click="filter" phx-value-status="active"
class={if @filter == "active", do: "active"}>
Active
</button>
<button phx-click="filter" phx-value-status="completed"
class={if @filter == "completed", do: "active"}>
Completed
</button>
</div>
<ul class="todo-list">
<%= for todo <- @todos do %>
<li class={if todo.done, do: "completed"}>
<input
type="checkbox"
checked={todo.done}
phx-click="toggle"
phx-value-id={todo.id}
/>
<span><%= todo.title %></span>
<button phx-click="delete" phx-value-id={todo.id}>×</button>
</li>
<% end %>
</ul>
<div class="stats">
<%= length(Enum.filter(@todos, &(!&1.done))) %> items left
</div>
</div>
"""
end
@impl true
def handle_event("add", %{"title" => title}, socket) when title != "" do
Todos.create_todo(%{title: title, done: false})
{:noreply, socket}
end
def handle_event("add", _, socket), do: {:noreply, socket}
def handle_event("toggle", %{"id" => id}, socket) do
todo = Todos.get_todo!(id)
Todos.toggle_todo(todo)
{:noreply, socket}
end
def handle_event("delete", %{"id" => id}, socket) do
todo = Todos.get_todo!(id)
Todos.delete_todo(todo)
{:noreply, socket}
end
def handle_event("filter", %{"status" => status}, socket) do
{:noreply, socket |> assign(filter: status) |> fetch_todos()}
end
# Tratar difusões de PubSub
@impl true
def handle_info({Todos, [:todo | _], _}, socket) do
{:noreply, fetch_todos(socket)}
end
defp fetch_todos(socket) do
todos = Todos.list_todos()
filtered = case socket.assigns[:filter] || "all" do
"active" -> Enum.filter(todos, &(!&1.done))
"completed" -> Enum.filter(todos, &(&1.done))
_ -> todos
end
assign(socket, todos: filtered, filter: socket.assigns[:filter] || "all")
end
end
Tratamento de formulários
Componente de formulário LiveView
defmodule MyAppWeb.PostLive.FormComponent do
use MyAppWeb, :live_component
alias MyApp.Blog
@impl true
def render(assigns) do
~H"""
<div class="post-form">
<.form
for={@form}
id="post-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<div class="form-group">
<.input field={@form[:title]} label="Title" />
</div>
<div class="form-group">
<.input field={@form[:body]} type="textarea" label="Content" />
</div>
<div class="form-group">
<.input field={@form[:published]} type="checkbox" label="Published" />
</div>
<button type="submit" phx-disable-with="Saving...">
Save Post
</button>
</.form>
</div>
"""
end
@impl true
def update(%{post: post} = assigns, socket) do
changeset = Blog.change_post(post)
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)}
end
@impl true
def handle_event("validate", %{"post" => post_params}, socket) do
changeset =
socket.assigns.post
|> Blog.change_post(post_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"post" => post_params}, socket) do
save_post(socket, socket.assigns.action, post_params)
end
defp save_post(socket, :edit, post_params) do
case Blog.update_post(socket.assigns.post, post_params) do
{:ok, post} ->
notify_parent({:saved, post})
{:noreply,
socket
|> put_flash(:info, "Post updated successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_post(socket, :new, post_params) do
case Blog.create_post(post_params) do
{:ok, post} ->
notify_parent({:saved, post})
{:noreply,
socket
|> put_flash(:info, "Post created successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, :form, to_form(changeset))
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end
Navegação Live
Padrão de modal
defmodule MyAppWeb.PostLive.Index do
use MyAppWeb, :live_view
alias MyApp.Blog
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :posts, Blog.list_posts())}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Post")
|> assign(:post, Blog.get_post!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Post")
|> assign(:post, %Blog.Post{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Posts")
|> assign(:post, nil)
end
@impl true
def render(assigns) do
~H"""
<.header>
Posts
<:actions>
<.link patch={~p"/posts/new"}>
<.button>New Post</.button>
</.link>
</:actions>
</.header>
<.table id="posts" rows={@streams.posts}>
<:col :let={{_id, post}} label="Title"><%= post.title %></:col>
<:col :let={{_id, post}} label="Status"><%= post.status %></:col>
<:action :let={{_id, post}}>
<.link patch={~p"/posts/#{post}/edit"}>Edit</.link>
</:action>
</.table>
<.modal :if={@live_action in [:new, :edit]} id="post-modal" show on_cancel={JS.patch(~p"/posts")}>
<.live_component
module={MyAppWeb.PostLive.FormComponent}
id={@post.id || :new}
title={@page_title}
action={@live_action}
post={@post}
patch={~p"/posts"}
/>
</.modal>
"""
end
end
Atualizações em tempo real
Monitorização de Presence
Monitorizar utilizadores a visualizar uma página:
defmodule MyAppWeb.Presence do
use Phoenix.Presence,
otp_app: :my_app,
pubsub_server: MyApp.PubSub
end
No LiveView:
defmodule MyAppWeb.RoomLive do
use MyAppWeb, :live_view
alias MyAppWeb.Presence
@impl true
def mount(%{"id" => room_id}, session, socket) do
topic = "room:#{room_id}"
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, topic)
{:ok, _} = Presence.track(self(), topic, session["user_id"], %{
online_at: DateTime.utc_now()
})
end
users = Presence.list(topic)
{:ok, assign(socket, room_id: room_id, users: users)}
end
@impl true
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
users = Presence.list("room:#{socket.assigns.room_id}")
{:noreply, assign(socket, users: users)}
end
end
Principais conclusões
- mount/3 para configuração: inicializar o estado quando o LiveView arranca
- handle_event/3 para interações: responder a ações do utilizador
- handle_info/2 para eventos externos: PubSub, temporizadores, etc.
- PubSub para sincronização multiutilizador: difundir alterações entre ligações
- assign para estado: todo o estado vive nos assigns do socket
- Streams para listas grandes: atualizações eficientes do DOM para coleções
O LiveView oferece a interatividade das SPAs, mantendo a complexidade no servidor, o que o torna ideal para equipas à vontade com Elixir que pretendem funcionalidades em tempo real sem frameworks JavaScript.