Elixir e Phoenix LiveView: Construindo uma Aplicação Todo List do Zero
Este é um tutorial completo e guiado para construir uma aplicação de Lista de Tarefas (Todo List) do zero, usando a stack moderna de Elixir com Phoenix LiveView — um framework funcional e reativo para o desenvolvimento web full-stack.
Mas há um diferencial: este guia é o segundo lado de uma mesma jornada.
No outro tutorial — Clojure/ClojureScript: Construindo uma Aplicação Persistente e Reativa — resolvemos o mesmo problema usando a stack Clojure + Reagent + next.jdbc, explorando o modelo de atualização reativa no navegador e a comunicação via API REST.
Aqui, faremos o mesmo conceitualmente, mas com Elixir e LiveView, onde frontend e backend se fundem em um único processo funcional e altamente performático.
🎯 Objetivo Pedagógico
O propósito deste tutorial não é apenas “fazer funcionar”, mas entender o porquê. Cada comando, cada função e cada módulo será explicado com contexto e analogia. Você aprenderá:
- Como o Phoenix organiza um projeto web;
- O que são schemas, migrations e changesets (e como se relacionam com os models dos ORMs tradicionais);
- Como o LiveView elimina a separação rígida entre frontend e backend, permitindo interações em tempo real sem escrever uma linha de JavaScript;
- E, claro, como criar, listar, marcar e excluir tarefas com atualização instantânea na interface.
⚙️ A Stack que Vamos Usar
- Linguagem: Elixir (baseada em Erlang, funcional e concorrente);
- Framework Web: Phoenix 1.8 + LiveView 1.1;
- Banco de Dados: SQLite (via Ecto);
- Estilo: Tailwind CSS v4 + daisyUI (já integrados ao Phoenix).
📌 Versões Utilizadas (Importante!)
Para que o tutorial seja reprodutível, estas são as versões de referência:
| Ferramenta / Biblioteca | Versão |
|---|---|
| Erlang/OTP | 26+ |
| Elixir | 1.17+ |
| Node.js | 18+ (LTS) |
Phoenix (phx_new) | 1.8.x |
| Phoenix LiveView | 1.1.x |
ecto_sql | ~> 3.10 |
ecto_sqlite3 | ~> 0.12 |
🔁 Um Mesmo Problema, Dois Caminhos Funcionais
| Aspecto | Clojure | Elixir |
|---|---|---|
| Paradigma | Funcional puro (imutabilidade explícita) | Funcional concorrente (processos leves) |
| Renderização | Frontend reativo com Reagent (React) | LiveView (renderização no servidor em tempo real) |
| Comunicação | API REST + JSON | Canal WebSocket interno (phx) |
| Persistência | next.jdbc + SQLite | Ecto + SQLite |
| Reatividade | r/atom (frontend) | Estado do socket (assigns) |
Ambos ensinam o mesmo princípio:
como o estado flui em uma aplicação funcional e reativa.
🧾 Os Marcos do Git (Seu Histórico Final)
Este tutorial também é um exercício de desenvolvimento incremental com Git. Cada fase termina em um commit; ao final, seu git log --oneline deve contar esta história (do mais recente para o mais antigo):
Fase 8: Atualiza README com instruções de execução
Fase 7: Ajusta o tema e personaliza o visual (Tailwind/daisyUI)
Fase 6: Implementa conclusão de tarefas (toggle_complete)
Fase 5: Implementa exclusão de tarefas (delete)
Fase 4: Refatora TodoLive para usar Ecto, Repo e to_form()
Fase 3: Persistência - Configura Ecto, Repo, Migrations e Task Schema
Fase 2: Lógica em Memória - Implementa adição de tarefas
Fase 1: Prova de Vida - Substitui a rota raiz por TodoLive
Fase 0: Gera o esqueleto do Phoenix com LiveView (sem Ecto)
Fase 0: Inicializa o repositório e .gitignore
📚 Índice das Fases
- ⚙️ Fase 0: Setup (Ambiente, Git e o Esqueleto do Projeto)
- 🏃 Fase 1: “Hello World” — Prova de Vida
- 🧠 Fase 2: Lógica em Memória
- 🧱 Fase 3: Persistência — A Camada de Dados (Ecto, Repo, Migration e Schema)
- 🫀 Fase 4: O “Transplante” — Conectando o LiveView ao Banco
- 🗑️ Fase 5: Refinamento — Excluindo Tarefas
- ✅ Fase 6: Refinamento — Concluindo Tarefas (Toggle)
- 🎨 Fase 7: Personalizando o Design (Tailwind CSS v4 e daisyUI)
- 📄 Fase 8: README e Entrega
💡 Um Tutorial a Duas Mãos
Assim como o tutorial em Clojure, este também é fruto de uma construção colaborativa — unindo clareza didática com profundidade técnica.
Prepare seu ambiente, abra o terminal e venha ver como Elixir + LiveView transforma o jeito de pensar aplicações web. 🚀
⚙️ Fase 0: Setup (Ambiente, Git e o Esqueleto do Projeto)
Objetivo: Preparar o ambiente, instalar as ferramentas e gerar o esqueleto do projeto. Mas antes, vamos entender o que é essa stack.
A Stack: Phoenix LiveView
Antes de começar, é crucial entender por que o LiveView é uma abordagem diferente — e por que ela vem ganhando tanto destaque entre desenvolvedores acostumados a React, Vue ou Next.js.
O Modo Tradicional (React / Next.js)
Em um modelo tradicional baseado em frameworks JavaScript, a aplicação é dividida em duas camadas independentes:
- Backend (API) — armazena e serve dados, geralmente em JSON.
- Frontend (SPA ou SSR) — construído em JavaScript, responsável pela interface, pelo estado e pela renderização dos componentes.
No React puro, o navegador recebe um HTML básico e depois baixa e executa o JavaScript que monta toda a interface (renderização no cliente). O Next.js otimiza isso com SSR/SSG, renderizando o HTML inicial no servidor — mas, após o carregamento, o React ainda assume o controle no cliente, “reidratando” a página.
Apesar de eficiente, esse modelo mantém o estado do aplicativo no navegador, exigindo sincronização constante com o backend via AJAX ou GraphQL. Isso significa duas fontes de verdade (cliente e servidor), gerenciamento de estado complexo e múltiplas camadas de código para manter tudo sincronizado.
O Modo LiveView (Estado no Servidor)
O Phoenix LiveView propõe algo radicalmente diferente:
👉 toda a lógica de estado e renderização vive no servidor.
O navegador abre uma conexão WebSocket — um “túnel” bidirecional e contínuo — com o servidor Phoenix. A partir daí, toda interação do usuário (como clicar em “Adicionar Tarefa”) envia apenas uma mensagem leve para o servidor: "o evento 'save_task' aconteceu".
O servidor Elixir processa o evento, atualiza o estado (a lista de tarefas) e re-renderiza o HTML no próprio servidor. Em seguida, calcula o que mudou (o diff) e envia apenas os fragmentos atualizados de volta. Um JavaScript minúsculo, incluído automaticamente pelo LiveView, faz o “remendo” na página — sem recarregar, sem sincronizar estados, sem React Hooks, sem Redux.
A Vantagem
- O estado vive em um só lugar — no servidor.
- Você escreve quase zero JavaScript.
- A interface é reativa em tempo real por padrão.
- A performance surpreende: o Elixir lida com milhares de conexões simultâneas graças ao modelo de concorrência leve da BEAM VM.
O LiveView traz a simplicidade do desenvolvimento tradicional (HTML + servidor) com a interatividade do front moderno (SPA) — sem manter duas aplicações separadas.
🧰 Passo 0.1: Instalar as Ferramentas
Precisamos de três coisas:
- Node.js — o Phoenix o usa para compilar assets (CSS/JS);
- Erlang — a máquina virtual (BEAM) sobre a qual o Elixir roda;
- Elixir — a linguagem de programação.
1. Node.js: baixe e instale a versão LTS no site oficial.
2. Elixir e Erlang (via script oficial): a maneira mais confiável, com controle exato das versões.
-
Linux/macOS (Bash):
# Baixa e executa o script, fixando as versões curl -fsSO https://elixir-lang.org/install.sh sh install.sh elixir@1.17.2 otp@26.2.2 installs_dir=$HOME/.elixir-install/installs # Adicione ao seu PATH (ex: no ~/.bashrc ou ~/.zshrc) # export PATH=$HOME/.elixir-install/installs/otp/26.2.2/bin:$PATH # export PATH=$HOME/.elixir-install/installs/elixir/1.17.2-otp-26/bin:$PATH -
Windows (PowerShell):
# Baixa e executa o script curl.exe -fsSO https://elixir-lang.org/install.bat .\install.bat elixir@1.17.2 otp@26.2.2 # Adicione os diretórios ao seu PATH de Ambiente de Usuário # (ex: %USERPROFILE%\.elixir-install\installs\otp\26.2.2\bin) # (ex: %USERPROFILE%\.elixir-install\installs\elixir\1.17.2-otp-26\bin)
Verificação: feche e reabra o terminal, e confirme que tudo respondeu com uma versão:
elixir --version # Elixir 1.17.x (compiled with Erlang/OTP 26)
node -v # v18 ou superior
git --version
(Se elixir não for encontrado, o PATH não foi configurado permanentemente — revise o passo acima antes de prosseguir.)
🧱 Passo 0.2: Instalar o Hex e o Gerador do Phoenix
O Mix é a ferramenta de build do Elixir — algo como o npm (Node.js), o pip (Python) ou o Maven (Java). Com ele, gerenciamos dependências, rodamos tarefas, executamos testes e criamos projetos. Ele já vem instalado junto com o Elixir.
O que é o Hex? O Hex é o gerenciador de pacotes oficial do Elixir — o papel que o npm faz para o JavaScript. Quando usarmos bibliotecas externas (como o Ecto), é o Hex quem as baixa. Instale-o com:
mix local.hex
O que é o Phoenix? O Phoenix é o principal framework web do ecossistema Elixir — comparável ao Django (Python), Rails (Ruby) ou Express (Node.js), mas construído para aproveitar ao máximo a concorrência e o tempo real da BEAM VM. Ele vem com o LiveView, que permite construir interfaces reativas sem JavaScript manual.
Instale o gerador de projetos:
mix archive.install hex phx_new
✅ Resumo:
- Mix → ferramenta de build e tarefas (como
npmoupip); - Hex → gerenciador de pacotes (como o registro do
npm); - Phoenix → framework web completo, com foco em performance e tempo real.
📁 Passo 0.3: O Diretório e o Git
O Git é nosso sistema de controle de versão. Vamos usá-lo desde o início para salvar o progresso em “checkpoints” (commits).
1. Crie o diretório do projeto:
mkdir elixir_todo_list
cd elixir_todo_list
2. Inicialize o Git:
git init
git branch -m main
3. Crie um .gitignore inicial.
Antes de gerar qualquer código, vamos garantir que o repositório só contenha o que é realmente necessário. O .gitignore diz ao Git o que não versionar — dependências, arquivos compilados, configurações locais e o arquivo do banco de dados (que criaremos na Fase 3: ele é resultado da aplicação, não código-fonte).
Crie o arquivo .gitignore (atenção à grafia: ponto + gitignore, sem letras faltando!) com o conteúdo:
/_build
/deps
/priv/static/assets
/node_modules
/assets/node_modules
*.log
/config/dev.secret.exs
.DS_Store
.vscode/
# --- Banco de Dados (SQLite, usado a partir da Fase 3) ---
*.db
*.db-shm
*.db-wal
💾 Passo 0.4: O Commit Inicial
git add .
git commit -m "Fase 0: Inicializa o repositório e .gitignore"
🧩 Passo 0.5: Gerar o Esqueleto do Projeto
Com o Mix e o gerador Phoenix instalados, vamos criar a estrutura da aplicação dentro do diretório atual:
mix phx.new . --no-ecto
🔍 Analisando o comando:
-
.→ o projeto será criado no diretório atual. (Se quiséssemos uma pasta nova, seriamix phx.new minha_app.) -
--no-ecto→ evita instalar o Ecto (a camada de banco de dados) por enquanto. Vamos adiar essa parte para a Fase 3, pois queremos primeiro entender o funcionamento “em memória” do LiveView.
💡 Comparando com outras stacks:
- No Django:
django-admin startproject nome_do_projeto. - No Rails:
rails new nome_do_projeto. - No Node.js:
npx create-next-appouexpress-generator.
Assim como nesses casos, o Phoenix gera um esqueleto completo de aplicação web, com pastas organizadas para templates, rotas, assets e (opcionalmente) banco de dados.
⚠️ Atenção às perguntas do gerador! Como a pasta não está vazia, o Phoenix fará algumas perguntas:
-
The directory ... already exists. Are you sure you want to continue?→ responda Y. -
.gitignore already exists, overwrite?→ responda Y (sim!). O.gitignoredo Phoenix é mais completo que o nosso — vamos aceitá-lo e recolocar nossas regras do banco em seguida. -
Fetch and install dependencies? [Yn]→ responda Y.
O Phoenix irá baixar todas as dependências Elixir (pelo Hex) e configurar os assets (Tailwind/esbuild), deixando o projeto pronto para rodar.
✂️ Passo 0.6: Reaplicar as Regras do Banco no .gitignore
Como aceitamos o .gitignore do Phoenix (que não conhece nosso futuro banco SQLite), abra o .gitignore e acrescente ao final:
# --- Banco de Dados (SQLite, usado a partir da Fase 3) ---
*.db
*.db-shm
*.db-wal
💾 Passo 0.7: O Commit do Esqueleto
Agora que temos a estrutura gerada (e o .gitignore completo), vamos versionar o ponto de partida:
git add .
git commit -m "Fase 0: Gera o esqueleto do Phoenix com LiveView (sem Ecto)"
Esse commit marca o início oficial do projeto: um esqueleto Phoenix totalmente funcional, com LiveView configurado, mas sem banco de dados — perfeito para explorar a lógica do LiveView em tempo real.
Fim da Fase 0! 🏁
🏃 Fase 1: “Hello World” — Prova de Vida
Objetivo: ligar o servidor Phoenix e verificar se tudo está funcionando — o primeiro “sinal de vida” do projeto. Em seguida, substituir a página padrão pelo nosso LiveView.
🔌 Passo 1.1: Ligar o Servidor
mix phx.server
O servidor será iniciado e exibirá mensagens no terminal. Na primeira execução, o projeto será compilado — pode demorar um pouco.
Abra o navegador e visite: 👉 http://localhost:4000
Se tudo deu certo, você verá a página de boas-vindas do Phoenix (com o logo e links para a documentação).
🧭 Passo 1.2: Alterar o Roteador
O Phoenix está exibindo a página padrão (controlada pelo PageController). Vamos trocá-la pelo nosso próprio LiveView, que será o coração da aplicação.
Abra o arquivo:
lib/elixir_todo_list_web/router.ex
Encontre o escopo principal e substitua a rota raiz /.
De:
scope "/", ElixirTodoListWeb do
pipe_through :browser
get "/", PageController, :home
end
Para:
scope "/", ElixirTodoListWeb do
pipe_through :browser
live "/", TodoLive, :index
end
A diferença está na palavra-chave live:
-
get→ responde com uma página renderizada uma única vez por um controller tradicional. -
live→ mantém uma conexão em tempo real (WebSocket), capaz de atualizar o conteúdo dinamicamente.
💥 Passo 1.3: O Primeiro Erro (e por que ele é bom)
Salve o arquivo e recarregue a página no navegador. Você verá um erro como:
** (UndefinedFunctionError) ... ElixirTodoListWeb.TodoLive ... is undefined
(module ElixirTodoListWeb.TodoLive is not available)
Excelente! 🎉 Isso significa que o Phoenix entendeu a nova rota, mas não encontrou o módulo TodoLive. Ou seja: a configuração está correta — só falta criarmos o módulo. Esse tipo de erro é um ótimo sinal no Phoenix: o compilador está te guiando sobre o que precisa existir.
🧱 Passo 1.4: Criar o LiveView
Crie o arquivo:
lib/elixir_todo_list_web/live/todo_live.ex
E adicione o seguinte código:
defmodule ElixirTodoListWeb.TodoLive do
use ElixirTodoListWeb, :live_view
# mount/3 é o "construtor" do LiveView — chamado quando a página é carregada
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
# render/1 define o HTML que será exibido
@impl true
def render(assigns) do
~H"""
<div class="p-12">
<h1 class="text-3xl font-bold">Meu Todo List (Hello LiveView!)</h1>
</div>
"""
end
end
O que está acontecendo aqui:
-
use ElixirTodoListWeb, :live_view→ diz ao Phoenix que este módulo é um LiveView (e já traz, de brinde, os componentes de UI e helpers que usaremos nas próximas fases). -
mount/3→ chamado quando a página é carregada; é o ponto de inicialização do “estado”. -
render/1→ retorna o HTML a ser exibido. -
~H""" ... """→ é o HEEx (HTML + Embedded Elixir), uma versão do HTML com suporte a expressões Elixir — similar ao JSX do React, mas processado no servidor e verificado em tempo de compilação.
Salve o arquivo e observe: o servidor recompila automaticamente, o erro desaparece e o navegador atualiza sozinho mostrando:
Meu Todo List (Hello LiveView!)
Isso confirma que o LiveView está funcionando: você acabou de renderizar sua primeira página dinâmica em tempo real, sem escrever uma linha de JavaScript.
💾 Passo 1.5: Commit
No segundo terminal (deixe o servidor rodando no primeiro):
git add .
git commit -m "Fase 1: Prova de Vida - Substitui a rota raiz por TodoLive"
Pronto! 🎯 Nosso projeto Phoenix com LiveView está oficialmente “vivo”.
🧠 Pausa Didática — Entendendo use, @impl true, mount e render
1. O que faz o use
A linha:
use ElixirTodoListWeb, :live_view
é uma forma especial de trazer comportamentos e configurações para o módulo atual — uma injeção de código feita durante a compilação.
💡 Em termos simples: o use importa automaticamente todas as funções, macros e configurações necessárias para que o módulo se comporte como um LiveView.
| Linguagem | Equivalente aproximado | O que faz |
|---|---|---|
| Java | extends BaseView | Herda métodos e atributos de uma classe base |
| Python | class MyView(BaseView): | Cria uma subclasse com comportamento herdado |
| Elixir | use ElixirTodoListWeb, :live_view | Injeta código e comportamentos no módulo atual |
➡️ O use não é herança, mas geração de código: ele deixa o módulo pronto para o ecossistema Phoenix. No nosso projeto, ele também já importa os componentes de UI (<.form>, <.input>, <.button>) e disponibiliza o alias Layouts — vamos usá-los a partir da Fase 4.
2. O papel do @impl true
O marcador @impl true vem antes de funções que implementam callbacks de um comportamento (behaviour). Um behaviour em Elixir é parecido com uma interface em linguagens orientadas a objetos: ele define quais funções um módulo deve implementar.
O LiveView espera que cada módulo tenha, no mínimo, mount/3 e render/1. Quando você escreve @impl true, está dizendo ao compilador: “esta função é a implementação esperada pelo comportamento do LiveView”.
| Linguagem | Anotação equivalente | Finalidade |
|---|---|---|
| Java | @Override | Indica que o método sobrescreve outro da interface/classe pai |
| Elixir | @impl true | Indica que a função implementa um callback de um comportamento |
Isso ajuda o compilador a verificar se o nome da função, a quantidade de parâmetros e o contrato do framework estão corretos.
3. Entendendo mount/3 e render/1
mount/3 — chamado quando o LiveView é carregado pela primeira vez. É como o construtor de uma classe: inicializa o estado (socket) e prepara dados.
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, tarefas: [])}
end
render/1 — gera o HTML da página a partir das variáveis (assigns):
@impl true
def render(assigns) do
~H"""
<div>
<h1>Minhas Tarefas</h1>
<ul>
<li :for={tarefa <- @tarefas}>{tarefa}</li>
</ul>
</div>
"""
end
4. O paralelo completo
| Conceito | Em Elixir (Phoenix) | Analogia em OO | Finalidade |
|---|---|---|---|
use | Injeta código e macros | extends + import | Reutilização de lógica base |
@impl true | Declara implementação de callback | @Override | Validação e clareza |
mount/3 | Inicializa o LiveView | Construtor da classe | Configura o estado inicial |
render/1 | Retorna o HTML dinâmico | render() / toString() | Define a interface visual |
Fim da Fase 1! 🏁
🧠 Fase 2: Lógica em Memória
Objetivo: construir a lógica que permite adicionar tarefas, sem ainda usar banco de dados.
Nesta fase, o foco é compreender como o Phoenix LiveView mantém o estado da interface em tempo real, usando apenas o socket — o elo entre cliente e servidor.
🧩 O que é o “estado” no LiveView?
No modelo LiveView, o estado da aplicação vive no servidor. Sempre que o usuário interage com a interface (digita, clica, envia um formulário), o navegador envia um pequeno evento via WebSocket. O servidor processa o evento, atualiza o estado e devolve o HTML atualizado.
Esse “estado” é armazenado no socket, uma estrutura que representa a sessão daquele usuário. Tudo o que a tela precisa saber — tarefas, campos de formulário, filtros — é guardado ali.
Socket = "memória viva" do LiveView
assign(socket, chave: valor) ➜ grava ou atualiza o estado
@chave ➜ acessa o valor no render
💡 Conexão com o tutorial de Clojure: o socket cumpre aqui o papel que o
r/atomcumpria no Reagent — a “caixa” observada cuja mudança redesenha a interface. A diferença: lá a caixa vivia no navegador; aqui, ela vive no servidor.
⚙️ Passo 2.1: O Estado Inicial (mount/3)
Quando o usuário abre a página, o LiveView chama mount/3. É aqui que criamos o estado inicial — uma mini “memória RAM” para aquele cliente.
Abra o arquivo:
lib/elixir_todo_list_web/live/todo_live.ex
e substitua o mount/3 atual por:
# @impl true → indicamos que esta função faz parte do comportamento LiveView
@impl true
def mount(_params, _session, socket) do
# Nossa "memória" inicial: duas tarefas de exemplo
tasks = [
%{id: 1, title: "Comprar leite", completed: false},
%{id: 2, title: "Aprender LiveView", completed: true}
]
# assign/2 coloca dados no socket (nosso estado de interface)
socket =
assign(socket,
tasks: tasks,
new_task_title: "" # o campo de texto começa vazio
)
# {:ok, socket} → retorna o estado inicial ao LiveView
{:ok, socket}
end
🎨 Passo 2.2: Mostrando o Estado (render/1)
A função render/1 é o “desenhista” da interface: recebe os dados do socket (via assigns) e devolve o HTML dinâmico.
Substitua o render/1 por:
@impl true
def render(assigns) do
~H"""
<div class="w-full max-w-lg mx-auto mt-12 p-6 bg-white rounded-lg shadow-md">
<h1 class="text-3xl font-bold mb-6 text-center text-gray-800">
Minha Lista de Tarefas
</h1>
<%!-- FORMULÁRIO DE ENTRADA --%>
<form phx-submit="save_task" phx-change="update_form" class="flex gap-2 mb-6">
<input
type="text"
name="title"
value={@new_task_title}
placeholder="O que precisa ser feito?"
class="flex-grow p-2 border rounded"
autofocus
/>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
Adicionar
</button>
</form>
<%!-- LISTA DE TAREFAS --%>
<div class="mt-8">
<ul id="task-list">
<li :for={task <- @tasks} class="flex justify-between items-center p-3 border-b">
<span class={if task.completed, do: "line-through text-gray-500", else: "text-gray-900"}>
{task.title}
</span>
</li>
</ul>
</div>
</div>
"""
end
🧭 Entendendo os atributos “mágicos”
| Atributo | Função |
|---|---|
phx-submit="save_task" | Dispara o evento "save_task" quando o formulário é enviado. |
phx-change="update_form" | Dispara o evento "update_form" a cada tecla digitada. |
value={@new_task_title} | Liga o campo de texto ao estado do socket. |
:for={task <- @tasks} | Repete o <li> para cada tarefa (o “for” do HEEx). |
⚠️ Passo 2.3: O “Aha!” — o texto que desaparece
Salve o arquivo e recarregue a página. Você verá o formulário e as duas tarefas de exemplo.
Agora digite algo no campo. Percebeu? O texto desaparece a cada letra!
Isso acontece porque o evento "update_form" é disparado, mas ainda não existe uma função para tratá-lo (handle_event). O processo LiveView falha, o supervisor o reinicia automaticamente (é assim que a BEAM lida com erros!), e o mount/3 roda de novo — limpando o campo. Dê uma olhada no terminal do servidor: o erro está registrado lá.
Essa é uma lição dupla: (1) todo evento phx-* precisa de um handle_event correspondente; (2) quando um processo LiveView “morre”, ele renasce do estado inicial.
🧩 Passo 2.4: Tratando a Digitação (update_form)
Vamos criar a função que captura o evento "update_form". Adicione entre o mount/3 e o render/1:
# Captura o evento de digitação no campo
@impl true
def handle_event("update_form", %{"title" => new_title}, socket) do
# Atualiza o valor do campo no estado
socket = assign(socket, new_task_title: new_title)
{:noreply, socket} # retorna o socket atualizado, sem recarregar a página
end
Salve e teste: agora o texto digitado permanece. O estado está sendo atualizado a cada tecla — e o LiveView reflete isso no HTML.
🧱 Passo 2.5: Tratando o Envio (save_task)
O próximo passo é realmente adicionar a nova tarefa à lista quando o usuário clicar em “Adicionar”. Adicione esta função logo abaixo da anterior:
# Captura o evento de envio do formulário
@impl true
def handle_event("save_task", %{"title" => title}, socket) do
if String.trim(title) != "" do
# Cria uma nova tarefa "em memória"
new_task = %{
id: System.unique_integer([:positive]),
title: title,
completed: false
}
# Atualiza a lista de tarefas e limpa o campo
socket =
socket
|> update(:tasks, fn tasks -> tasks ++ [new_task] end)
|> assign(:new_task_title, "")
{:noreply, socket}
else
# Ignora caso o campo esteja vazio
{:noreply, socket}
end
end
| Função | Explicação |
|---|---|
System.unique_integer/1 | Gera um ID único (suficiente para uso “em memória”). |
update/3 | Atualiza um valor existente no socket aplicando uma função (a lista :tasks). |
assign/3 | Redefine new_task_title para limpar o input. |
🎉 Resultado
Você tem um Todo List funcional, mesmo sem banco de dados! Adicione algumas tarefas e veja a lista crescer instantaneamente.
O teste da verdade: agora recarregue a página (F5). As tarefas que você adicionou sumiram — só as duas de exemplo voltaram. E se você reiniciar o servidor, o mesmo acontece.
Isso é esperado: as tarefas viviam apenas na memória daquele socket. Cada F5 abre uma conexão nova, e o mount/3 recomeça do zero. Você acabou de ganhar a motivação para a Fase 3: persistência de verdade.
O que aprendemos — o coração do LiveView:
- O estado mora no socket;
- Cada evento é tratado por um
handle_event/3; - O
render/1reflete o estado atual em HTML, sem recarregar a página.
💾 Passo 2.6: Commit
git add .
git commit -m "Fase 2: Lógica em Memória - Implementa adição de tarefas"
🧠 Pausa Didática — Entendendo o Estilo Elixir
Antes de continuar, vamos fazer uma pausa estratégica para compreender a estrutura e o estilo da linguagem que aparecem no nosso código. Mesmo com poucas linhas, o todo_live.ex já demonstra vários conceitos centrais: módulos, funções, imutabilidade, pattern matching e o pipe.
Este é o estado atual do nosso arquivo (confira se o seu está igual):
defmodule ElixirTodoListWeb.TodoLive do
use ElixirTodoListWeb, :live_view
@impl true
def mount(_params, _session, socket) do
tasks = [
%{id: 1, title: "Comprar leite", completed: false},
%{id: 2, title: "Aprender LiveView", completed: true}
]
socket =
assign(socket,
tasks: tasks,
new_task_title: ""
)
{:ok, socket}
end
@impl true
def handle_event("update_form", %{"title" => new_title}, socket) do
socket = assign(socket, new_task_title: new_title)
{:noreply, socket}
end
@impl true
def handle_event("save_task", %{"title" => title}, socket) do
if String.trim(title) != "" do
new_task = %{
id: System.unique_integer([:positive]),
title: title,
completed: false
}
socket =
socket
|> update(:tasks, fn tasks -> tasks ++ [new_task] end)
|> assign(:new_task_title, "")
{:noreply, socket}
else
{:noreply, socket}
end
end
@impl true
def render(assigns) do
~H"""
... (o HTML completo do Passo 2.2) ...
"""
end
end
1️⃣ Módulos — a “caixa” do código
Em Elixir, tudo vive dentro de um módulo (defmodule). Um módulo é como uma caixa que organiza funções relacionadas — parecido com uma classe, mas com uma diferença fundamental: 👉 módulos não possuem estado interno nem herança. Eles apenas agrupam funções. O estado vive em estruturas como o socket ou em processos — nunca no módulo em si.
2️⃣ Funções e Aridade
Funções são definidas com def (públicas) ou defp (privadas). O número após a barra é a aridade — quantos parâmetros a função recebe:
-
mount/3→ 3 parâmetros; -
render/1→ 1 parâmetro; -
handle_event/3→ 3 parâmetros.
Em Elixir, a aridade faz parte do nome da função: handle_event/2 e handle_event/3 são funções diferentes, e não sobrecargas como em Java ou C#.
3️⃣ Pattern Matching — a escolha automática
Observe:
def handle_event("update_form", %{"title" => new_title}, socket) do ... end
def handle_event("save_task", %{"title" => title}, socket) do ... end
Temos duas funções com o mesmo nome e aridade, e o Elixir decide qual chamar pelo padrão dos argumentos: se o primeiro argumento for "update_form", executa a primeira; se for "save_task", a segunda. Isso é o pattern matching — um dos pilares do paradigma funcional.
Em Python, escreveríamos:
def handle_event(event_name, payload, socket):
if event_name == "update_form":
...
elif event_name == "save_task":
...
Em Elixir, a própria definição da função já é o “if”.
4️⃣ Tuplas — a maneira Elixir de retornar
Em vez de retornar valores “crus”, o Elixir usa tuplas etiquetadas:
{:ok, socket}
{:noreply, socket}
{:error, "algo deu errado"}
A primeira posição é sempre um átomo (:ok, :noreply, :error) descrevendo o status. Essa convenção vem do Erlang e facilita o controle de fluxo entre processos.
5️⃣ O Socket — nosso “estado vivo”
socket = assign(socket, tasks: tasks, new_task_title: "")
- O
socketé a “caixa de estado” daquela sessão; -
assignnão muta a caixa: cria uma nova versão dela (imutabilidade!); - Cada atualização do socket dispara uma nova renderização automática.
6️⃣ O Operador Pipe (|>) — elegância funcional
socket
|> update(:tasks, fn tasks -> tasks ++ [new_task] end)
|> assign(:new_task_title, "")
Lê-se: “pegue o socket, atualize as tarefas e depois limpe o campo de texto”. O pipe passa o resultado de cada linha como primeiro argumento da próxima, eliminando variáveis intermediárias — como uma receita passo a passo.
📘 Recapitulando
| Conceito | Significado | Analogia |
|---|---|---|
defmodule | Agrupa funções relacionadas | Classe sem estado |
def / defp | Funções públicas / privadas | Métodos |
| Aridade | Nº de parâmetros (parte do nome) | — |
| Pattern Matching | Escolhe a função pelo padrão dos argumentos | Substitui if/switch |
Tuplas {:ok, ...} | Retornos estruturados | return (status, valor) |
\|> (pipe) | Encadeia transformações | Fluent interface |
socket | Estado imutável da interface | Store/State no React |
Fim da Fase 2! 🏁
Agora estamos prontos para conectar nossa lógica em memória a um banco de dados real com Ecto.
🧱 Fase 3: Persistência — A Camada de Dados (Ecto, Repo, Migration e Schema)
Na fase anterior, nossa aplicação mantinha as tarefas apenas em memória. Agora daremos um passo importante: armazenar os dados de forma permanente usando o Ecto, a biblioteca de persistência do Elixir.
Optamos por não incluir o Ecto desde o início (--no-ecto) para que você entendesse primeiro a lógica interna do LiveView. Agora veremos como adicionar novas dependências a um projeto existente e configurar o banco de dados real.
Nesta fase, montamos apenas a camada de dados (dependências, Repo, migration e schema) e a testamos isoladamente. O “transplante” — trocar o coração em memória do TodoLive pelo banco — fica para a Fase 4.
⚙️ Passo 3.1: Adicionar as Dependências do Ecto
Pare o servidor (Ctrl+C duas vezes) e abra o arquivo mix.exs, o equivalente ao package.json (JavaScript) ou requirements.txt (Python). É nele que declaramos as dependências do projeto, dentro da função defp deps do.
Ação: localize defp deps do no mix.exs e adicione estas quatro linhas em qualquer ponto da lista (por exemplo, logo após a linha do {:phoenix_live_view, ...}), mantendo tudo o que já existe:
defp deps do
[
{:phoenix, "~> 1.8.1"},
# ... (todas as dependências que o phx.new gerou — NÃO apague nada!) ...
# --- ADICIONE ESTAS 4 LINHAS (suporte ao Ecto) ---
{:ecto, "~> 3.11"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.10"},
{:ecto_sqlite3, "~> 0.12"}, # SQLite: banco leve, baseado em arquivo
# --------------------------------------------------
# ... (o restante das dependências geradas) ...
]
end
O que cada uma faz:
| Dependência | Papel |
|---|---|
ecto | O núcleo: schemas, changesets e queries. |
ecto_sql | A camada SQL do Ecto (migrations, adaptadores). |
ecto_sqlite3 | O “driver” específico do SQLite. |
phoenix_ecto | A cola entre o Phoenix e o Ecto (ex: integração de changesets com formulários). |
Em seguida, baixe as dependências:
mix deps.get
🧩 Passo 3.2: Configurar o “Repo” — o Agente do Banco de Dados
O Repo (de repository) é o módulo que representa a conexão com o banco. É como o settings.py do Django ou o database.yml do Rails: ele sabe onde está o banco e como acessá-lo.
Ação 1: crie o arquivo lib/elixir_todo_list/repo.ex:
defmodule ElixirTodoList.Repo do
use Ecto.Repo,
otp_app: :elixir_todo_list,
adapter: Ecto.Adapters.SQLite3
end
Ação 2: agora precisamos supervisionar o Repo — garantir que ele seja iniciado (e reiniciado, se cair) junto com a aplicação. Abra lib/elixir_todo_list/application.ex e adicione o Repo no início da lista de processos supervisionados:
children = [
ElixirTodoList.Repo, # 👈 ADICIONE ESTA LINHA (antes dos demais)
ElixirTodoListWeb.Telemetry,
{DNSCluster, query: Application.get_env(:elixir_todo_list, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: ElixirTodoList.PubSub},
# ...
ElixirTodoListWeb.Endpoint
]
Ação 3: por fim, configure o Repo no arquivo config/config.exs. Adicione (por exemplo, logo após a linha import Config no topo):
config :elixir_todo_list, ElixirTodoList.Repo,
database: "elixir_todo_list.db",
priv: "priv/repo"
config :elixir_todo_list, ecto_repos: [ElixirTodoList.Repo]
Entendendo as opções:
-
database:→ o arquivo onde o SQLite salvará tudo. Com esse valor, ele será criado na raiz do projeto (elixir_todo_list.db). -
priv:→ onde ficam os arquivos de apoio do Repo — em especial, as migrations (priv/repo/migrations/). Atenção: isso não muda o local do banco! -
ecto_repos:→ informa às tarefas do Mix (mix ecto.create,mix ecto.migrate) quais Repos existem.
🏗️ Passo 3.3: Criar o Banco de Dados
O banco ainda não existe. Crie-o com:
mix ecto.create
Resultado Esperado:
The database for ElixirTodoList.Repo has been created
Olhe a raiz do projeto: o arquivo elixir_todo_list.db apareceu. (E rode git status para confirmar que ele não aparece para o Git — obrigado, .gitignore!)
🧬 Passo 3.4: Migrations — a “Planta Baixa” do Banco
Em praticamente todos os frameworks modernos (Django, Rails, Laravel), usamos migrations para versionar e aplicar mudanças no banco. Cada migration é um pequeno “passo evolutivo”: criar uma tabela, adicionar uma coluna, etc.
Ação 1: gere a migration da nossa tabela de tarefas:
mix ecto.gen.migration create_tasks_table
Isso cria um arquivo dentro de priv/repo/migrations/ (o nome começa com um timestamp — é assim que o Ecto sabe a ordem de aplicação).
Ação 2: abra o arquivo gerado e complete a função change:
defmodule ElixirTodoList.Repo.Migrations.CreateTasksTable do
use Ecto.Migration
def change do
create table(:tasks) do
add :title, :string
add :completed, :boolean, default: false
timestamps() # Adiciona as colunas inserted_at e updated_at
end
end
end
Ação 3: aplique a migration:
mix ecto.migrate
Saída esperada:
[info] == Running ... ElixirTodoList.Repo.Migrations.CreateTasksTable.change/0 forward
[info] create table tasks
[info] == Migrated ... in 0.0s
📘 Passo 3.5: Conceitos-Chave — Schema, Changeset e Form
Para conectar o código à tabela recém-criada, precisamos de três peças:
| Conceito | O que é | Analogia |
|---|---|---|
| Schema | Define a estrutura da tabela e cria uma struct %Task{} correspondente. | O “model” do Django ou o ActiveRecord do Rails. |
| Changeset | Um conjunto de regras para validar e transformar dados antes de salvar. | O ModelForm do Django. |
to_form/1 | Converte o changeset para o formato que o LiveView usa no <.form>. | Um form object no MVC. |
🧩 Passo 3.6: Criar o Schema Task
Crie o arquivo lib/elixir_todo_list/task.ex:
defmodule ElixirTodoList.Task do
use Ecto.Schema
import Ecto.Changeset
# "schema" espelha a tabela "tasks" no banco
schema "tasks" do
field :title, :string
field :completed, :boolean, default: false
timestamps(type: :utc_datetime)
end
# Define como validar os dados antes de salvar
def changeset(task_struct, attrs) do
task_struct
|> cast(attrs, [:title, :completed])
|> validate_required([:title])
end
end
-
cast/3→ filtra os dados de entrada, aceitando apenas os campos listados (:title,:completed) — proteção contra dados indesejados; -
validate_required/2→ garante que:titlenão está vazio. É esta linha que fará o formulário exibir “can’t be blank” na Fase 4.
🧪 Passo 3.7: Testando a Camada de Dados no IEx (sem o LiveView!)
Assim como fizemos no REPL do Clojure, vamos provar que a camada de dados funciona antes de conectá-la à interface. A versão Elixir do REPL é o IEx (Interactive Elixir), e podemos iniciá-lo com o projeto carregado:
iex -S mix
No prompt iex(1)>, experimente:
# Atalhos para digitar menos
iex> alias ElixirTodoList.{Repo, Task}
# 1. O banco está vazio?
iex> Repo.all(Task)
[]
# 2. Crie uma tarefa (validando com o changeset)
iex> %Task{} |> Task.changeset(%{title: "Testar o IEx"}) |> Repo.insert()
{:ok, %ElixirTodoList.Task{id: 1, title: "Testar o IEx", completed: false, ...}}
# 3. E uma inválida? (sem título)
iex> %Task{} |> Task.changeset(%{}) |> Repo.insert()
{:error, #Ecto.Changeset<..., errors: [title: {"can't be blank", ...}], valid?: false>}
# 4. Confira a lista
iex> Repo.all(Task)
[%ElixirTodoList.Task{id: 1, title: "Testar o IEx", ...}]
Momento “Aha!”: repare nas tuplas {:ok, ...} e {:error, changeset} — são exatamente os dois casos que trataremos com case no LiveView, na próxima fase. E a tarefa inválida não foi salva: o changeset barrou antes de chegar ao banco.
Saia do IEx com Ctrl+C duas vezes.
💾 Passo 3.8: Commit (Camada de Dados)
git add .
git commit -m "Fase 3: Persistência - Configura Ecto, Repo, Migrations e Task Schema"
Fim da Fase 3! 🏁
Agora temos a estrutura completa:
- O banco de dados criado e versionado por migrations;
- O
Repogerenciando conexões e consultas (supervisionado!); - O schema
Taskrefletindo nossa tabela, com validação via changeset — tudo testado no IEx.
Falta o passo mais gratificante: trocar o “coração” em memória do TodoLive por esse novo, persistente.
🫀 Fase 4: O “Transplante” — Conectando o LiveView ao Banco
Já temos o banco criado, o schema configurado e o Repo operacional (Fase 3). Chegou o momento de conectar tudo ao nosso LiveView.
Chamamos esta etapa de “transplante” porque substituímos o coração antigo (estado em memória) por um novo (persistência real) — sem mudar o corpo (a estrutura mount/eventos/render permanece a mesma).
🔍 Visão Geral: o que vai mudar?
Antes, nossa aplicação:
- Mantinha as tarefas apenas no socket (memória);
- Cada F5 apagava tudo;
- O formulário era um
<form>“cru”, com o estado do campo gerenciado manualmente (update_form).
Agora:
- O
Repovira a ponte entre o LiveView e o banco; - O schema
Tasksubstitui os mapas manuais; - O changeset +
to_form/1cuidam da validação e da integração com o formulário; - E as mensagens de sucesso aparecerão via flash.
1️⃣ Passo 4.1: O Setup do Módulo (aliases)
Abra lib/elixir_todo_list_web/live/todo_live.ex e ajuste o topo do módulo:
defmodule ElixirTodoListWeb.TodoLive do
use ElixirTodoListWeb, :live_view
# Atalhos (aliases) para não digitar o nome completo toda hora
alias ElixirTodoList.Repo
alias ElixirTodoList.Task
-
aliaspermite escreverRepo.all(...)em vez deElixirTodoList.Repo.all(...).
2️⃣ Passo 4.2: O “Construtor” — Carregamento Inicial (mount/3)
Substitua o mount/3 por:
@impl true
def mount(_params, _session, socket) do
tasks = Repo.all(Task) # Lê as tarefas do banco de dados
changeset = Task.changeset(%Task{}, %{}) # Cria um "molde" vazio
form = to_form(changeset) # Converte para o formulário de UI
socket =
assign(socket,
tasks: tasks,
form: form
)
{:ok, socket}
end
O que mudou?
| Antes | Agora |
|---|---|
| Lista fixa de tarefas escrita no código. | Tarefas reais do banco, via Repo.all(Task). |
Um simples new_task_title no estado. | Um changeset convertido em form — validável e integrado. |
💡 O
mount/3é como oget()de uma view do Django ou oindex()de um controller Rails: define o estado inicial da página.
3️⃣ Passo 4.3: O “Coração” — Salvando no Banco (handle_event/3)
Agora, remova as duas funções handle_event da Fase 2 ("update_form" e "save_task") e coloque no lugar esta única função:
@impl true
def handle_event("save_task", %{"task" => task_params}, socket) do
# 1. Cria um changeset com os dados do formulário
changeset = Task.changeset(%Task{}, task_params)
# 2. Tenta inserir no banco — e trata os DOIS resultados possíveis
socket_atualizado =
case Repo.insert(changeset) do
# 2A. SUCESSO!
{:ok, _new_task} ->
novo_changeset_vazio = Task.changeset(%Task{}, %{})
socket
|> assign(:tasks, Repo.all(Task)) # Recarrega as tarefas
|> assign(:form, to_form(novo_changeset_vazio)) # Reseta o formulário
|> put_flash(:info, "Tarefa salva com sucesso!")
# 2B. FALHA! (validação — ex: título em branco)
{:error, failed_changeset} ->
# Re-atribui o formulário *com os erros* para exibi-los
assign(socket, form: to_form(failed_changeset))
end
# 3. Retorna o socket atualizado
{:noreply, socket_atualizado}
end
Mudanças-chave:
- Antes, adicionávamos a tarefa direto na lista do socket → agora,
Repo.insert(changeset)salva no banco. - As tuplas
{:ok, ...}/{:error, ...}que vimos no IEx (Fase 3) reaparecem aqui, tratadas pelocase. -
put_flash/3prepara uma mensagem amigável (como omessages.success()do Django).
💡 Repare que não existe mais o
handle_event("update_form", ...). Por quê? Ao trocar o<form>cru pelo componente<.form>comto_form(próximo passo), quem passa a controlar o valor do campo é o próprio mecanismo de formulários do Phoenix — não precisamos mais rastrear cada tecla.
4️⃣ Passo 4.4: O “Desenhista” — Renderização com <.form> e Layouts.app
Substitua o render/1 por:
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="w-full max-w-lg mx-auto mt-12 p-6 bg-white rounded-lg shadow-md">
<h1 class="text-3xl font-bold mb-6 text-center text-gray-800">
Minha Lista de Tarefas (com DB!)
</h1>
<%!-- O formulário agora usa @form --%>
<.form for={@form} id="task-form" phx-submit="save_task">
<.input
field={@form[:title]}
type="text"
label="Nova Tarefa"
placeholder="O que precisa ser feito?"
/>
<.button variant="primary" phx-disable-with="Salvando...">Adicionar Tarefa</.button>
</.form>
<%!-- A LISTA DE TAREFAS --%>
<div class="mt-8">
<ul id="task-list">
<li :for={task <- @tasks} class="flex justify-between items-center p-3 border-b">
<span class={if task.completed, do: "line-through text-gray-500", else: "text-gray-900"}>
{task.title}
</span>
</li>
</ul>
</div>
</div>
</Layouts.app>
"""
end
Destaques:
-
<Layouts.app flash={@flash}>— este envelope é essencial: no Phoenix 1.8, é dentro dele que vivem o cabeçalho padrão da aplicação e oflash_group— o componente que exibe as mensagens doput_flash. Sem esse envelope, oput_flash(:info, "Tarefa salva...")do passo anterior rodaria… e a mensagem nunca apareceria na tela. -
<.form for={@form}>— o componente de formulário integrado ao changeset. Repare que os campos agora chegam ao servidor “embrulhados”:%{"task" => %{"title" => "..."}}— por isso o pattern dohandle_eventmudou para%{"task" => task_params}. -
<.input field={@form[:title]}>— o campo integrado: exibe o valor, o label e as mensagens de erro de validação, tudo automaticamente. -
<.button variant="primary">— o botão estilizado do daisyUI;phx-disable-withtroca o texto enquanto o envio está em andamento.
🧪 Passo 4.5: Testando Tudo
Suba o servidor (mix phx.server) e acesse http://localhost:4000:
- Sucesso: adicione “Tarefa 1” → ela aparece na lista, o formulário limpa e a mensagem “Tarefa salva com sucesso!” surge no topo. ✅
- Falha (validação): clique em “Adicionar Tarefa” com o campo em branco → aparece o erro “can’t be blank” junto ao campo. ✅
- O teste que falhava na Fase 2: adicione “Tarefa 2” e recarregue a página (F5) → as tarefas continuam lá. ✅
- A prova final: pare o servidor (
Ctrl+Cduas vezes) e suba de novo (mix phx.server) → F5 → tudo continua lá. Persistência real! 🎉
💾 Passo 4.6: Commit
git add .
git commit -m "Fase 4: Refatora TodoLive para usar Ecto, Repo e to_form()"
Fim da Fase 4! 🏁
Com essa refatoração, nosso app deixou de ser um protótipo volátil e virou uma aplicação completa, com:
- Banco de dados real (Ecto + SQLite);
- Validação automática via changeset;
- Formulário dinâmico com
to_form()e mensagens de flash funcionando; - Interface reativa com Phoenix LiveView.
🔮 Próximos desafios
- Adicionar o botão de excluir (
phx-click="delete") → Fase 5; - Adicionar o checkbox de conclusão (
phx-change="toggle_complete") → Fase 6.
🗑️ Fase 5: Refinamento — Excluindo Tarefas
🎯 Objetivo
Adicionar um botão “X” ao lado de cada tarefa para que, ao ser clicado, ela seja removida permanentemente do banco de dados e da interface, sem recarregar a página.
💡 O que você aprenderá aqui
- Como disparar eventos de clique com
phx-click(sem formulários); - Como enviar dados específicos para o servidor (o ID da tarefa) com
phx-value-*; - Como interagir com o banco via
Repo.geteRepo.delete; - E, de quebra, um pouco mais da linguagem de template do LiveView — o HEEx.
🧱 Passo 5.1: Atualizando a Interface (o botão “X”)
Abra o arquivo:
lib/elixir_todo_list_web/live/todo_live.ex
Na função render/1, localize o bloco <ul>...</ul> que exibe as tarefas. Dentro do <li>, adicione um botão ao lado do título.
Antes:
<li :for={task <- @tasks} class="flex justify-between items-center p-3 border-b">
<span class={if task.completed, do: "line-through text-gray-500", else: "text-gray-900"}>
{task.title}
</span>
</li>
Depois (com o botão de exclusão):
<li :for={task <- @tasks} class="flex justify-between items-center p-3 border-b">
<span class={if task.completed, do: "line-through text-gray-500", else: "text-gray-900"}>
{task.title}
</span>
<%!-- NOVO BOTÃO EXCLUIR --%>
<.button
type="button"
phx-click="delete"
phx-value-id={task.id}
class="!p-2 !bg-red-600 hover:!bg-red-700 text-white font-bold rounded-full"
>
× <%!-- Renderiza um "X" --%>
</.button>
</li>
🧠 Entendendo o Template (HEEx)
O Phoenix usa o HEEx (HTML + Embedded Elixir Extended) — parecido com os templates do Django (Jinja), o ERB do Rails —, mas com uma diferença marcante: tudo é verificado em tempo de compilação, inclusive atributos e componentes.
| Sintaxe | Função |
|---|---|
{task.title} | Interpola um valor Elixir no HTML, com escape seguro. |
<%!-- ... --%> | Comentário do HEEx (não vai para o navegador). |
<.button> | Um componente do Phoenix — uma função Elixir que gera HTML. |
:for={task <- @tasks} | Estrutura de repetição do HEEx. |
⚡ Entendendo os Atributos do Botão
| Atributo | Significado |
|---|---|
phx-click="delete" | Ao clicar, envia o evento "delete" para o servidor (via WebSocket). |
phx-value-id={task.id} | Envia o ID da tarefa junto — o servidor o recebe como %{"id" => "1"} (repare: string!). |
class="!p-2 !bg-red-600 ..." | Classes Tailwind; o ! força prioridade sobre o estilo padrão do componente. |
× | Entidade HTML do símbolo “×”. |
🧩 Comparando com outros frameworks
| Framework | Como enviaria o ID no evento? |
|---|---|
| Django | Formulário <form> ou JavaScript + fetch() |
| Rails | link_to "X", task_path(task), method: :delete, remote: true |
| Flask | Formulário POST + rota /delete/<id> |
| Phoenix LiveView | Apenas: phx-click="delete" phx-value-id={task.id} — o resto viaja pelo WebSocket. |
💥 Passo 5.2: Hora do Erro (e da Descoberta)
Salve o arquivo. Os botões “X” aparecem. Agora clique em um deles e olhe o terminal:
❌
** (UndefinedFunctionError) ... no function clause matching in ElixirTodoListWeb.TodoLive.handle_event/3(ou erro similar)
O Phoenix está certo: enviamos o evento "delete", mas nenhuma cláusula de handle_event combina com ele — o pattern matching não encontrou destino. Igualzinho ao “texto que desaparece” da Fase 2: evento sem tratador derruba (e reinicia) o processo.
⚙️ Passo 5.3: Implementando o handle_event("delete", ...)
Adicione esta função logo abaixo do handle_event("save_task", ...):
@impl true
def handle_event("delete", %{"id" => id}, socket) do
# 1. Busca a tarefa correspondente no banco
# (se o id não existir, Repo.get retorna nil)
task = Repo.get(Task, id)
# 2. Remove a tarefa (apenas se ela existir)
if task do
Repo.delete(task)
end
# 3. Atualiza a lista de tarefas na tela e avisa o usuário
socket =
socket
|> assign(:tasks, Repo.all(Task))
|> put_flash(:info, "Tarefa removida com sucesso!")
# 4. Retorna o novo estado
{:noreply, socket}
end
🧠 Explicando em detalhes
| Etapa | Descrição |
|---|---|
def handle_event("delete", %{"id" => id}, socket) | O pattern matching “escuta” o evento "delete"; o segundo parâmetro traz o mapa com o phx-value-id. |
Repo.get(Task, id) | Busca a tarefa pelo ID (o Ecto converte a string para o tipo da chave). Retorna nil se não existir. |
if task do ... end | Defesa contra cliques em botões “velhos” (ex: a tarefa já foi removida em outra aba). |
Repo.delete(task) | Exclui o registro do banco. |
assign(socket, :tasks, Repo.all(Task)) | Recarrega a lista — a UI re-renderiza sem a tarefa excluída. |
put_flash(:info, ...) | A mensagem de confirmação — exibida pelo Layouts.app que adicionamos na Fase 4. |
🧩 O ciclo completo do evento
- Usuário clica no botão “X” →
- O navegador envia
"delete"com%{"id" => "3"}pelo WebSocket → - O servidor executa
handle_event("delete", ...)→ - O LiveView atualiza o estado (
assign/3) → - O template HEEx é re-renderizado automaticamente →
- A lista aparece na tela já sem a tarefa — em tempo real!
🔍 Passo 5.4: Testando
- Salve o arquivo (o servidor recompila sozinho).
- Acesse
http://localhost:4000. - Crie algumas tarefas e clique no “X” vermelho de uma delas.
✅ A lista se atualiza imediatamente, com o aviso: “Tarefa removida com sucesso!”
🔁 Recarregue a página (F5): a tarefa continua excluída — o DELETE persistiu no banco.
💾 Passo 5.5: Commit
git add .
git commit -m "Fase 5: Implementa exclusão de tarefas (delete)"
Fim da Fase 5! 🏁
Seu TodoLive agora cria, valida, lista e exclui tarefas em tempo real — usando apenas Elixir + LiveView, sem JavaScript.
Falta o “U” do CRUD: marcar tarefas como concluídas.
✅ Fase 6: Refinamento — Concluindo Tarefas (Toggle)
🎯 Objetivo
Adicionar um checkbox ao lado de cada tarefa. Ao clicar, ele deve marcar a tarefa como concluída (ou não concluída) no banco de dados, atualizando a interface em tempo real — aplicando ou removendo o estilo “riscado”.
💡 Por que isso é importante?
Este passo ensina a:
- Criar formulários dinâmicos dentro de loops (
:for); - Usar
phx-changepara reagir instantaneamente a um checkbox; - Atualizar registros existentes com
Repo.get+Repo.update; - Reutilizar o
Task.changeset/2para validação e atualização; - E a decifrar uma pegadinha clássica dos checkboxes HTML. 🕵️
🧩 Passo 6.1: A Interface (adicionando o checkbox)
A maneira mais robusta de lidar com checkboxes no LiveView é criar um pequeno <.form> por tarefa — assim cada item da lista tem seu próprio estado independente.
Abra lib/elixir_todo_list_web/live/todo_live.ex, encontre o loop <li :for={task <- @tasks}> no render/1 e substitua o <span> do título por um formulário com checkbox. O bloco da lista completo fica assim:
<%!-- A LISTA DE TAREFAS --%>
<div class="mt-8">
<ul id="task-list">
<li
:for={task <- @tasks}
id={"task-#{task.id}"}
class="flex justify-between items-center p-3 border-b"
>
<% task_form = Task.changeset(task, %{}) |> to_form() %>
<.form
for={task_form}
phx-change="toggle_complete"
phx-value-id={task.id}
class="flex-grow"
>
<div class="flex items-center space-x-4">
<.input
type="checkbox"
field={task_form[:completed]}
class="flex-shrink-0"
/>
<%!-- Um <label> separado para controle total do estilo --%>
<label class={if task.completed, do: "line-through text-gray-500", else: "text-gray-900"}>
{task.title}
</label>
</div>
</.form>
<.button
type="button"
phx-click="delete"
phx-value-id={task.id}
class="!p-2 !bg-red-600 hover:!bg-red-700 text-white font-bold rounded-full"
>
×
</.button>
</li>
</ul>
</div>
🧠 Explicação didática
| Elemento | Função |
|---|---|
<% task_form = ... %> | Cria uma variável temporária: o formulário individual daquela tarefa (um changeset “vazio” sobre a tarefa existente, convertido com to_form). |
id={"task-#{task.id}"} | Um id único por <li> — ajuda o LiveView a rastrear cada item. |
phx-change="toggle_complete" | Evento disparado automaticamente ao marcar/desmarcar o checkbox. |
phx-value-id={task.id} | Envia o ID da tarefa junto ao evento. |
<.input type="checkbox" field={task_form[:completed]}> | O checkbox integrado ao campo completed do schema (já renderiza marcado/desmarcado conforme o banco). |
<label class={...}> | Aplica o “riscado” quando a tarefa está concluída. |
💥 Erro esperado: salve o arquivo e clique em um checkbox. Como na Fase 5, o terminal mostrará um erro — o evento "toggle_complete" ainda não tem tratador. Já sabemos o remédio.
🕵️ Passo 6.2: A Pegadinha do Checkbox (leia antes de codar!)
Antes de escrever o handle_event, precisamos entender o que exatamente chega ao servidor quando o checkbox muda. E aqui mora uma pegadinha dupla:
1. HTML puro: um checkbox desmarcado não é enviado em formulários HTML. Se dependêssemos disso, “desmarcar” não geraria dado nenhum.
2. A solução do Phoenix: para contornar isso, o componente <.input type="checkbox"> renderiza, escondido, um segundo campo:
<input type="hidden" name="task[completed]" value="false" /> <input type="checkbox" name="task[completed]" value="true" ... />
Graças ao campo oculto, o servidor sempre recebe a chave "completed":
- Checkbox marcado →
%{"task" => %{"completed" => "true"}} - Checkbox desmarcado →
%{"task" => %{"completed" => "false"}}
⚙️ Passo 6.3: Implementando o handle_event("toggle_complete", ...)
Adicione a função logo abaixo do handle_event("delete", ...):
@impl true
def handle_event("toggle_complete", %{"id" => id, "task" => task_params}, socket) do
# 1. Busca a tarefa correspondente no banco
task = Repo.get!(Task, id)
# 2. Lê o novo estado do checkbox
# (graças ao campo oculto, "completed" é sempre "true" ou "false")
completed_status = task_params["completed"] == "true"
# 3. Cria um changeset de ATUALIZAÇÃO (repare: partimos de 'task',
# a struct vinda do banco — não de %Task{} vazio!)
changeset = Task.changeset(task, %{completed: completed_status})
# 4. Atualiza o registro no banco de dados
Repo.update(changeset)
# 5. Recarrega a lista para atualizar a UI
socket = assign(socket, tasks: Repo.all(Task))
{:noreply, socket}
end
🧩 O fluxo, passo a passo
-
Evento recebido — ao clicar no checkbox, o navegador envia:
%{"id" => "1", "task" => %{"completed" => "true"}} # marcou %{"id" => "1", "task" => %{"completed" => "false"}} # desmarcou - Busca —
Repo.get!localiza a tarefa (aqui usamos a versão com!: se o ID sumiu, algo está muito errado e preferimos o erro explícito). - Conversão —
task_params["completed"] == "true"transforma a string do formulário em um booleano de verdade. (Ecoa a lição do tutorial de Clojure, onde convertíamos o 0/1 do SQLite — formulários e bancos adoram fingir que booleanos são outra coisa!) - Atualização —
Task.changeset(task, ...)+Repo.updateaplicam a mudança. É o mesmochangeset/2da criação: como ele parte da struct recebida, serve tanto parainsertquanto paraupdate. - Re-render — o
assignatualiza@tasks, e o LiveView redesenha o template.
🧪 Passo 6.4: Teste Final (o CRUD completo)
Com o servidor rodando, em http://localhost:4000:
- Create: adicione duas ou três tarefas. ✅
- Read: F5 — continuam lá. ✅
- Update: marque o checkbox de uma → ela risca imediatamente. Desmarque → o risco some (se não sumir, revise o Passo 6.2!). F5 → o estado persiste. ✅
- Delete: clique no “X” de outra → some. F5 → continua fora. ✅
- A prova final: pare o servidor, suba de novo, F5 → tudo exatamente como você deixou. ✅
💾 Passo 6.5: Commit
git add .
git commit -m "Fase 6: Implementa conclusão de tarefas (toggle_complete)"
📄 O Código Completo (para conferência)
Se algo não funcionou, compare seu lib/elixir_todo_list_web/live/todo_live.ex com o estado final:
defmodule ElixirTodoListWeb.TodoLive do
use ElixirTodoListWeb, :live_view
alias ElixirTodoList.Repo
alias ElixirTodoList.Task
@impl true
def mount(_params, _session, socket) do
tasks = Repo.all(Task)
changeset = Task.changeset(%Task{}, %{})
form = to_form(changeset)
socket =
assign(socket,
tasks: tasks,
form: form
)
{:ok, socket}
end
@impl true
def handle_event("save_task", %{"task" => task_params}, socket) do
changeset = Task.changeset(%Task{}, task_params)
socket_atualizado =
case Repo.insert(changeset) do
{:ok, _new_task} ->
novo_changeset_vazio = Task.changeset(%Task{}, %{})
socket
|> assign(:tasks, Repo.all(Task))
|> assign(:form, to_form(novo_changeset_vazio))
|> put_flash(:info, "Tarefa salva com sucesso!")
{:error, failed_changeset} ->
assign(socket, form: to_form(failed_changeset))
end
{:noreply, socket_atualizado}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
task = Repo.get(Task, id)
if task do
Repo.delete(task)
end
socket =
socket
|> assign(:tasks, Repo.all(Task))
|> put_flash(:info, "Tarefa removida com sucesso!")
{:noreply, socket}
end
@impl true
def handle_event("toggle_complete", %{"id" => id, "task" => task_params}, socket) do
task = Repo.get!(Task, id)
completed_status = task_params["completed"] == "true"
changeset = Task.changeset(task, %{completed: completed_status})
Repo.update(changeset)
socket = assign(socket, tasks: Repo.all(Task))
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="w-full max-w-lg mx-auto mt-12 p-6 bg-white rounded-lg shadow-md">
<h1 class="text-3xl font-bold mb-6 text-center text-gray-800">
Minha Lista de Tarefas (com DB!)
</h1>
<.form for={@form} id="task-form" phx-submit="save_task">
<.input
field={@form[:title]}
type="text"
label="Nova Tarefa"
placeholder="O que precisa ser feito?"
/>
<.button variant="primary" phx-disable-with="Salvando...">Adicionar Tarefa</.button>
</.form>
<div class="mt-8">
<ul id="task-list">
<li
:for={task <- @tasks}
id={"task-#{task.id}"}
class="flex justify-between items-center p-3 border-b"
>
<% task_form = Task.changeset(task, %{}) |> to_form() %>
<.form
for={task_form}
phx-change="toggle_complete"
phx-value-id={task.id}
class="flex-grow"
>
<div class="flex items-center space-x-4">
<.input
type="checkbox"
field={task_form[:completed]}
class="flex-shrink-0"
/>
<label class={if task.completed, do: "line-through text-gray-500", else: "text-gray-900"}>
{task.title}
</label>
</div>
</.form>
<.button
type="button"
phx-click="delete"
phx-value-id={task.id}
class="!p-2 !bg-red-600 hover:!bg-red-700 text-white font-bold rounded-full"
>
×
</.button>
</li>
</ul>
</div>
</div>
</Layouts.app>
"""
end
end
Fim da Fase 6! 🏁
Você agora tem um aplicativo CRUD completo em Elixir + Phoenix LiveView:
- Criar novas tarefas (com validação);
- Listar as existentes;
- Atualizar (marcar/desmarcar como concluída);
- Excluir tarefas.
Tudo com atualização em tempo real e sem escrever uma linha de JavaScript. 💪
🎨 Fase 7: Personalizando o Design (Tailwind CSS v4 e daisyUI)
Até aqui, construímos uma aplicação funcional e interativa. Agora vamos entender como o visual é definido e como personalizá-lo.
O Phoenix 1.8 vem configurado com o Tailwind CSS v4, um framework CSS baseado em classes utilitárias, e com o daisyUI, um plugin que fornece componentes prontos (botões, alertas, inputs) e temas pré-configurados.
🔧 Passo 7.1: Como o Tailwind funciona
O Tailwind é diferente de frameworks tradicionais como o Bootstrap. Em vez de classes de componentes prontos, ele oferece pequenas classes de propósito único:
| Propósito | Classe Tailwind | Efeito |
|---|---|---|
| Cor de fundo | bg-blue-500 | Fundo azul médio |
| Cor do texto | text-gray-800 | Texto cinza escuro |
| Tamanho do texto | text-3xl | Texto grande |
| Espaçamento interno | p-4 | Padding de 1rem |
| Bordas arredondadas | rounded-lg | Cantos suavizados |
| Sombra | shadow-md | Sombra média |
Essas classes se combinam livremente — é o padrão usado em todo o nosso TodoLive:
<div class="w-full max-w-lg mx-auto mt-12 p-6 bg-white rounded-lg shadow-md">
<h1 class="text-3xl font-bold mb-6 text-center text-gray-800">
Minha Lista de Tarefas (com DB!)
</h1>
-
w-full max-w-lg mx-autocentraliza o bloco com largura máxima; -
bg-white rounded-lg shadow-mdcria um “cartão” branco com sombra; -
text-3xl font-bold text-gray-800formata o título.
🌈 Passo 7.2: daisyUI — o “tema visual” do Tailwind
O daisyUI adiciona classes mais semânticas para componentes comuns. Observe o componente <.button> no arquivo lib/elixir_todo_list_web/components/core_components.ex:
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
As classes btn, btn-primary e btn-soft vêm do daisyUI — é por isso que escrevemos <.button variant="primary"> na Fase 4. Outros exemplos no mesmo arquivo: alert-info/alert-error (mensagens flash), checkbox e input (formulários).
A grande vantagem: esses componentes obedecem ao tema ativo (claro, escuro, etc.) sem que você redefina cores manualmente.
🌗 Passo 7.3: Os Temas (e um mistério resolvido)
Faça um teste: se o seu sistema operacional estiver no modo escuro, abra a aplicação. Notou que partes da página (o fundo, o cabeçalho do Layouts.app, os inputs) ficam escuras, enquanto o nosso “cartão” continua branco (bg-white)? O contraste fica estranho.
O que está acontecendo? O Phoenix 1.8 já vem com dois temas daisyUI (um claro e um escuro) definidos no assets/css/app.css:
@plugin "../vendor/daisyui" {
themes: false;
}
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true; /* 👈 segue a preferência do sistema! */
...
}
@plugin "../vendor/daisyui-theme" {
name: "light";
default: true;
...
}
E o root.html.heex inclui um pequeno script que aplica o tema conforme o atributo data-theme (ou a preferência do sistema, por causa do prefersdark: true).
Como forçar o tema claro (a opção mais simples para o nosso visual): abra
lib/elixir_todo_list_web/components/layouts/root.html.heex
e adicione data-theme="light" ao <body>:
Mude:
<body>
{@inner_content}
</body>
Para:
<body data-theme="light">
{@inner_content}
</body>
Pronto: a aplicação fica no tema claro para todos, independentemente da preferência do sistema.
💡 Passo 7.4: Exemplos práticos de personalização
Quer destacar mais as tarefas concluídas? Ajuste a classe condicional no TodoLive:
<label class={
if task.completed,
do: "line-through text-gray-400 italic",
else: "text-gray-900 font-medium"
}>
{task.title}
</label>
Quer um botão de exclusão mais discreto? É só trocar as classes:
<.button
type="button"
phx-click="delete"
phx-value-id={task.id}
class="!p-1 !bg-red-500 hover:!bg-red-700"
>
×
</.button>
(O ! prefixando a classe força a prioridade sobre o estilo padrão do componente <.button>.)
Sem tocar em arquivos CSS, você molda toda a interface.
💾 Passo 7.5: Commit
git add .
git commit -m "Fase 7: Ajusta o tema e personaliza o visual (Tailwind/daisyUI)"
Fim da Fase 7! 🏁
O Tailwind (com daisyUI) é o que dá ao Phoenix sua agilidade visual: elimina o CSS manual, mantém o template declarativo e ainda permite personalização total. A partir daqui, você pode experimentar temas, criar componentes próprios e dar identidade à sua aplicação.
Falta um último passo para o projeto ficar apresentável ao mundo: a 📄 Fase 8: README e Entrega.
📄 Fase 8: README e Entrega
Objetivo: transformar o README.md padrão do Phoenix em uma documentação de verdade e fazer o checklist final do repositório.
Por que fazemos isso? O README é a porta de entrada de qualquer repositório — o primeiro (às vezes o único) arquivo que um colega, recrutador ou avaliador vai ler. O critério de qualidade é simples: alguém conseguiria clonar e rodar seu projeto lendo apenas o README?
📝 Passo 8.1: Atualizar o README.md
O phx.new já gerou um README.md genérico na raiz do projeto. Vamos substituí-lo por um personalizado. Adapte o modelo (nome, links, o que quiser):
# Todo List — Elixir + Phoenix LiveView
**Aluno(a):** Seu Nome Completo Aqui
**Tutorial original:** [Como Criar um App "Todo List" com Elixir e LiveView
do Zero](https://profsergiocosta.notion.site/Como-Criar-um-App-Todo-List-com-Elixir-e-LiveView-do-Zero-2a8cce97509380eba53fc82bbeb08435)
## Descrição
Aplicação de lista de tarefas (Todo List) construída de forma incremental
para estudar a arquitetura **full-stack funcional reativa** do
[Phoenix LiveView](https://hexdocs.pm/phoenix_live_view):
- **Framework:** Phoenix 1.8 + LiveView 1.1 — o estado vive no servidor e a
interface atualiza em tempo real via WebSocket, sem JavaScript manual;
- **Persistência:** [Ecto](https://hexdocs.pm/ecto) + SQLite (schema,
migrations e validação com changesets);
- **Visual:** Tailwind CSS v4 + daisyUI.
Funcionalidades: criar tarefas (com validação), listar, marcar como
concluída (toggle) e excluir — tudo com atualização instantânea.
## Pré-requisitos
| Ferramenta | Versão |
| ---------- | --------- |
| Erlang/OTP | 26+ |
| Elixir | 1.17+ |
| Node.js | 18+ (LTS) |
## Como Rodar
```bash
git clone https://github.com/SEU-USUARIO/SEU-REPO.git
cd SEU-REPO
# Instala dependências e prepara os assets
mix setup
# Cria o banco SQLite e aplica as migrations
mix ecto.create
mix ecto.migrate
# Sobe o servidor
mix phx.server
```
Depois, abra [http://localhost:4000](http://localhost:4000) no navegador.
## Estrutura Principal
| Arquivo | Papel |
| -------------------------------------------- | ----------------------------------- |
| `lib/elixir_todo_list_web/live/todo_live.ex` | O LiveView (estado, eventos e HTML) |
| `lib/elixir_todo_list/task.ex` | O schema `Task` e seu changeset |
| `lib/elixir_todo_list/repo.ex` | A conexão com o banco (Ecto.Repo) |
| `priv/repo/migrations/` | As migrations do banco |
🧪 Passo 8.2: O Teste do “Colega”
Faça de conta que você é outra pessoa: clone o seu repositório em uma pasta nova (ex: /tmp/teste-todo) e siga o seu próprio README, literalmente. A aplicação subiu? O CRUD funciona? Os dados persistem após reiniciar o servidor?
Esse teste revela os dois vacilos clássicos:
-
Banco commitado: se
git status/a página do GitHub mostrarelixir_todo_list.db(ou.db-shm/.db-wal), o.gitignoreda Fase 0 não foi aplicado antes do commit. Corrija com:git rm --cached elixir_todo_list.db elixir_todo_list.db-shm elixir_todo_list.db-wal git commit -m "Remove arquivos de banco do versionamento" -
Passo de setup faltando no README (ex: esquecer o
mix ecto.migrate— sem ele, a aplicação sobe mas quebra ao carregar as tarefas).
💾 Passo 8.3: Commit Final
git add README.md
git commit -m "Fase 8: Atualiza README com instruções de execução"
✅ Passo 8.4: Checklist Final
Confira o histórico:
git log --oneline
Esperado (de cima para baixo):
Fase 8: Atualiza README com instruções de execução
Fase 7: Ajusta o tema e personaliza o visual (Tailwind/daisyUI)
Fase 6: Implementa conclusão de tarefas (toggle_complete)
Fase 5: Implementa exclusão de tarefas (delete)
Fase 4: Refatora TodoLive para usar Ecto, Repo e to_form()
Fase 3: Persistência - Configura Ecto, Repo, Migrations e Task Schema
Fase 2: Lógica em Memória - Implementa adição de tarefas
Fase 1: Prova de Vida - Substitui a rota raiz por TodoLive
Fase 0: Gera o esqueleto do Phoenix com LiveView (sem Ecto)
Fase 0: Inicializa o repositório e .gitignore
Checklist:
- O repositório no GitHub é público e o
git pushfoi feito? - Nenhum arquivo
*.db,*.db-shmou*.db-walaparece no GitHub? - As pastas
_build/edeps/também não aparecem? - O README tem nome, link do tutorial, descrição e instruções que funcionam?
- O CRUD completo funciona e os dados sobrevivem ao restart do servidor?
🚀 Tutorial Concluído!
Parabéns! 🥳 Você construiu uma aplicação web full-stack funcional e reativa — e, se fez também o tutorial de Clojure, agora viu o mesmo problema resolvido por duas filosofias:
| Clojure | Elixir | |
|---|---|---|
| Onde vive o estado reativo | No navegador (r/atom) | No servidor (socket/assigns) |
| Como a UI conversa com os dados | API REST + JSON + CORS | WebSocket interno (phx-*) |
| Onde a validação acontece | No handler, “à mão” | No changeset, declarativa |
| Quantos servidores em dev | Dois (API + shadow-cljs) | Um (mix phx.server) |
Quer ir além? Algumas extensões (opcionais):
- Um filtro “Todas / Ativas / Concluídas” (um
assign+ cláusulas dehandle_event); - Edição do título de uma tarefa (outro pequeno
<.form>por item); - Um contador “X de Y concluídas” no topo;
- Trocar o SQLite por PostgreSQL (
ecto_sqlite3→postgrex, e o adapter no Repo); - Explorar o Phoenix PubSub para que duas abas do navegador vejam as mudanças uma da outra em tempo real — a “mágica” concorrente da BEAM em ação.
Enjoy Reading This Article?
Here are some more articles you might like to read next: