Criando aplicações interativas com Phoenix LiveView
Crie uma busca com Phoenix LiveView, estado no servidor, templates HEEx, eventos e um hook JavaScript integrado. Veja quando o LiveView é adequado.
O Phoenix LiveView renderiza HTML no servidor, mantém o estado de cada página em um processo Elixir de longa duração e envia ao navegador apenas as partes da página que mudaram, por meio de uma conexão persistente.
Se você desenvolve telas de CRUD em React ou Vue, conhece bem a rotina: um endpoint JSON, um store no lado do cliente, estados de carregamento e validação escrita uma vez em cada lado. A maior parte desse código serve apenas para transportar dados entre duas cópias do mesmo estado.
Este artigo mostra como o LiveView elimina a camada de API nesse tipo de aplicação. Você vai criar uma caixa de busca em tempo real com a sintaxe do LiveView 1.2, adicionar um trecho de JavaScript no lado do cliente e ver onde o modelo deixa de ser adequado.
Principais pontos
- Um LiveView mantém o estado da UI em um processo no servidor. Os eventos do navegador trafegam por um WebSocket, e o LiveView devolve apenas o HTML que mudou.
- A primeira renderização de um LiveView é uma resposta HTTP comum, e o
mount/3é executado novamente quando o WebSocket se conecta. - Um LiveView básico precisa de três callbacks:
mount/3define o estado inicial,render/1retorna um template HEEx ehandle_event/3atualiza o estado em resposta a bindings comophx-clickephx-change. - Os colocated hooks colocam o JavaScript de um componente em uma tag
<script>ao lado da sua marcação. Eles exigem o Phoenix 1.8 ou superior. - Cada evento tratado no servidor é uma viagem de ida e volta pela rede. React ou Vue continuam sendo a melhor escolha para aplicações offline-first e interfaces com muita interação no lado do cliente.
O que o Phoenix LiveView faz?
O Phoenix LiveView transfere o estado, o tratamento de eventos e a renderização do navegador para o servidor, enquanto o navegador mantém uma conexão ativa para receber atualizações. A documentação do LiveView descreve cada LiveView como um processo: ele recebe eventos, altera seu estado e envia à página apenas as partes que diferem.
Comparando com o modelo de SPA que você já conhece:
| Conceito de SPA | Equivalente no LiveView |
|---|---|
Estado do componente (useState, ref) | Assigns no socket do lado do servidor |
Handler de evento (onClick) | Callback handle_event/3 |
| Patch do Virtual DOM | Diff de HTML enviado pelo WebSocket |
| Chamada de API para buscar dados | Uma chamada de função dentro do mesmo processo |
O ciclo de vida ocorre em duas fases. A primeira renderização é uma resposta HTTP comum, então a página fica visível e indexável antes de qualquer JavaScript ser carregado. Em seguida, o navegador abre um WebSocket, e o mount/3 é executado uma segunda vez dentro de um processo que permanece ativo enquanto a página estiver aberta. Portanto, o código em mount/3 é executado duas vezes a cada carregamento de página.
Se você já usou htmx, a ideia de conduzir a UI com HTML renderizado no servidor vai soar familiar. A diferença é que o LiveView também mantém um processo com estado no servidor para cada página conectada.
Como criar um projeto Phoenix e adicionar uma rota live?
Uma nova aplicação Phoenix 1.8 já vem com o LiveView configurado, então um único comando do gerador fornece tudo o que este exemplo precisa. Depois de instalar o Elixir e o instalador do Phoenix, execute:
mix phx.new my_app --no-ecto
cd my_app
mix phx.server
A flag --no-ecto dispensa a camada de banco de dados, que uma demonstração não precisa. O servidor de desenvolvimento escuta em http://localhost:4000. Você não precisa da flag --live: tutoriais mais antigos a utilizam, mas o LiveView agora faz parte de toda aplicação gerada.
Abra lib/my_app_web/router.ex. Dentro do bloco scope "/", MyAppWeb do existente, abaixo de pipe_through :browser, adicione:
live "/search", SearchLive
A macro live mapeia um caminho para um módulo LiveView, da mesma forma que get mapeia um caminho para um controller. Substitua pelo nome da sua aplicação onde aparecer MyApp. O Phoenix vem com geradores, scaffolding de autenticação e LiveView prontos para uso, o que o torna um framework “batteries-included”.
Como criar uma caixa de busca em tempo real com Phoenix LiveView?
Uma caixa de busca em tempo real precisa de um binding de formulário que reaja à digitação e de um binding de clique que redefina o estado. Crie lib/my_app_web/live/search_live.ex:
defmodule MyAppWeb.SearchLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, query: "", results: frameworks())}
end
def render(assigns) do
~H"""
<form phx-change="search" phx-submit="search">
<input
id="search-input"
type="text"
name="q"
value={@query}
placeholder="Filter frameworks"
autocomplete="off"
phx-debounce="300"
/>
</form>
<button phx-click="clear">Clear</button>
<p>{length(@results)} matches</p>
<ul>
<li :for={name <- @results}>{name}</li>
</ul>
"""
end
def handle_event("search", %{"q" => q}, socket) do
{:noreply,
socket
|> assign(:query, q)
|> assign(:results, filter(q))}
end
def handle_event("clear", _params, socket) do
{:noreply, assign(socket, query: "", results: frameworks())}
end
defp frameworks do
["Phoenix", "Rails", "Laravel", "Django", "Next.js", "Nuxt", "SvelteKit", "Remix"]
end
defp filter(q) do
needle = String.downcase(q)
Enum.filter(frameworks(), &String.contains?(String.downcase(&1), needle))
end
end
O mount/3 recebe params, session e socket, nessa ordem, e retorna o socket com os assigns iniciais. O render/1 retorna um template HEEx criado com o sigil ~H. Cada um dos bindings phx-change, phx-submit e phx-click envia um evento nomeado para o handle_event/3, que faz o match pelo nome do evento, retorna {:noreply, socket} com os assigns atualizados e deixa o LiveView enviar um diff do HTML afetado.
O phx-change é disparado conforme o usuário digita, então cada tecla pressionada geraria uma viagem de ida e volta. O phx-debounce limita a frequência desses disparos e aceita milissegundos ou "blur". Apontar o phx-submit para o mesmo evento faz com que o LiveView trate a tecla Enter, em vez de o navegador enviar o formulário. O guia de form bindings mostra que os parâmetros do formulário chegam como um map com chaves do tipo string, nomeadas de acordo com os inputs, como %{"q" => "pho"}.
Os templates fazem interpolação com {@query}. Tutoriais que usam <%= @query %> são anteriores à sintaxe atual. O atributo :for é o atalho do HEEx para uma comprehension.
O Elixir que você precisa saber para este arquivo:
- Atoms como
:ok,:noreplye:querysão constantes nomeadas, semelhantes a enums de strings. - Tuplas como
{:noreply, socket}são grupos de valores de tamanho fixo. Os callbacks as retornam para indicar o que aconteceu. - Cabeçalhos de função com pattern matching: as duas cláusulas de
handle_event/3são definições separadas, e o Elixir executa aquela cujo primeiro argumento corresponde ao nome do evento.%{"q" => q}desestrutura os parâmetros da mesma forma queconst { q } = paramsfaz em JavaScript. - Pipes:
|>passa o valor à sua esquerda como primeiro argumento da próxima chamada. - Imutabilidade:
assignretorna um novo socket. Nada é modificado no lugar.
Como adicionar JavaScript no lado do cliente com um colocated hook?
Um colocated hook permite escrever o JavaScript de um componente em uma tag <script> logo ao lado da sua marcação. O nome do hook começa com um ponto, e o elemento ao qual ele se vincula deve ter um id de DOM único. O guia de JS interop aborda a API completa de hooks.
O exemplo abaixo adiciona atalhos de teclado: / coloca o foco no campo de busca e Escape o limpa. Substitua o <input> em render/1 por:
<input id="search-input" type="text" name="q" value={@query}
placeholder="Filter frameworks" autocomplete="off"
phx-debounce="300"
phx-hook=".SearchShortcuts" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".SearchShortcuts">
export default {
mounted() {
this.onKey = (e) => {
if (e.key === "/" && document.activeElement !== this.el) {
e.preventDefault()
this.el.focus()
}
if (e.key === "Escape" && document.activeElement === this.el) {
this.el.value = ""
this.el.blur()
this.pushEvent("clear", {})
}
}
window.addEventListener("keydown", this.onKey)
},
destroyed() {
window.removeEventListener("keydown", this.onKey)
}
}
</script>
O mounted é executado quando o elemento é adicionado à página, e o destroyed, quando ele é removido; assim, o listener é registrado uma única vez e depois removido. O pushEvent("clear", {}) aciona a mesma cláusula handle_event("clear", ...) usada pelo botão, de modo que cliente e servidor compartilham um único fluxo de código. Limpar o campo e tirar o foco dele antes de enviar o evento mantém o valor visível sincronizado com o estado do servidor.
O módulo ColocatedHook extrai o script em tempo de compilação. Um nome iniciado por ponto recebe o namespace do nome do módulo, e o recurso exige o Phoenix 1.8 ou superior. O app.js gerado pelo mix phx.new já registra esses hooks:
import {hooks as colocatedHooks} from "phoenix-colocated/my_app"
// ...
hooks: {...colocatedHooks}
Quando o LiveView é adequado e quando não é?
Cada evento tratado pelo handle_event/3 é uma viagem de ida e volta até o servidor. Um LiveView parece instantâneo em uma rede rápida e lento em uma rede ruim. Alterações puramente visuais, como abrir um dropdown, podem ser executadas no navegador com os comandos Phoenix.LiveView.JS, sem nunca chegar ao servidor.
O localhost esconde essa latência. O assets/js/app.js gerado pelo mix phx.new já expõe o socket como window.liveSocket; portanto, para simular uma conexão lenta, execute isto no console do navegador:
liveSocket.enableLatencySim(500)
// later
liveSocket.disableLatencySim()
O simulador de latência adiciona o número de milissegundos informado como latência de ida e de volta ao servidor. Em produção, session replays de aplicações LiveView em conexões lentas mostram o mesmo problema do ponto de vista do usuário: um input que fica atrasado em relação à digitação e um “piscar” quando a página se desconecta e reconecta.
O LiveView tem mais dois custos. O suporte offline é difícil, porque os eventos precisam de uma conexão ativa com o servidor. Além disso, você precisa executar Elixir em produção, com um processo por página conectada.
| Situação | Melhor opção | Por quê |
|---|---|---|
| Telas de CRUD, dashboards, formulários, ferramentas administrativas | LiveView | Sem camada de API separada; estado e renderização ficam no servidor |
| Aplicações offline-first ou com conectividade ruim | React / Vue | O LiveView precisa de uma conexão ativa para tratar eventos |
| Muita interação no lado do cliente (editores, canvas, interfaces com muito drag-and-drop) | React / Vue | Cada interação custa uma viagem de ida e volta; hooks ajudam, mas não substituem um framework de cliente |
| Equipe sem disposição para trabalhar com Elixir | React / Vue | É preciso executar e manter Elixir no servidor |
Para uma visão mais ampla do trade-off entre UIs conduzidas pelo servidor e pelo cliente, veja esta comparação entre htmx, Vue e React.
Conclusão
O LiveView é uma boa opção para aplicações no estilo CRUD que, de outra forma, precisariam de uma API JSON e de um store no lado do cliente. Você escreve callbacks em um único módulo, o servidor mantém o estado, e hooks ou comandos JS cobrem o que precisa ser executado no navegador. Um bom próximo passo é gerar uma aplicação Phoenix 1.8, reconstruir um formulário da sua SPA atual como um LiveView e ativar o simulador de latência para ver como ele se comporta em uma conexão lenta antes de tomar uma decisão.
Perguntas frequentes
O que acontece quando um LiveView perde a conexão WebSocket?
O cliente se reconecta automaticamente com back-off exponencial, e o mount/3 é executado novamente; portanto, quaisquer assigns que não sejam reconstruídos no mount/3 são perdidos. Formulários que têm tanto um atributo id quanto um binding phx-change se recuperam automaticamente: após a reconexão, o LiveView reenvia os valores atuais do formulário pelo evento phx-change. Adicione phx-auto-recover para acionar um evento de recuperação diferente, ou defina-o como 'ignore' para desativar a recuperação.
Como evitar que o código no mount/3 seja executado duas vezes?
Envolva-o em uma verificação connected?(socket). Phoenix.LiveView.connected?/1 retorna false durante a renderização HTTP estática e true depois que o WebSocket se conecta. Assim, subscriptions, timers e consultas custosas dentro de um bloco if connected?(socket) são executados apenas no processo live. Mantenha os assigns padrão de baixo custo fora da verificação, para que a renderização estática ainda produza um HTML completo e indexável.
Como testar um LiveView sem um navegador?
Use o Phoenix.LiveViewTest, que vem com o LiveView e é executado sob o ExUnit. Chame live(conn, path) para montar a view e obter um handle para ela; em seguida, interaja com ela usando helpers como render_click e render_change e faça asserções sobre o HTML que eles retornam. Esses testes se comunicam diretamente com o processo do LiveView, então o JavaScript dos hooks no lado do cliente não é executado neles.
Qual é a diferença entre um function component e um LiveComponent?
Um function component é uma função sem estado que recebe assigns e retorna HEEx, de modo que os eventos da sua marcação vão para o LiveView pai. Um LiveComponent, definido com use Phoenix.LiveComponent, mantém seu próprio estado e trata seus próprios eventos quando sua marcação define phx-target={@myself}. Ambos são executados dentro do processo do LiveView pai. Use function components por padrão e escolha um LiveComponent apenas quando precisar manter o estado e o tratamento de eventos dentro do componente.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k