Welcome to this tutorial on how to create a todo list with Phoenix LiveView!
Phoenix LiveView is a powerful framework that allows you to build interactive, real-time web applications that are fast without sacrificing reliability using Elixir and the Phoenix web framework.
In this tutorial, we'll build a simple to-do list application that demonstrates the power and simplicity of Phoenix LiveView. We will be adding tests too 😍 along the way.
This simple app allows users to add, mark as complete, and delete tasks in real-time. It has filters that display all, completed, and active tasks as shown in the gif below.
Application Structure.
We will have one LiveView
(todos_live.ex) and two LiveComponents
(form_component.ex and todos_list_component.ex) with their corresponding templates in separate files as seen in the two images below. I used this approach to mimic how real apps are normally structured.
LiveComponents are a mechanism to compartmentalize state, markup, and events in LiveView. You can read more about them from the docs.
Prerequisites.
This tutorial assumes the following dependencies are installed on your machine; Erlang, Elixir, Phoenix, Node.js, and Postgres.
Elixir is the language we'll be using, Erlang is the language it's built on, Phoenix is the web framework, Node supports the system JavaScript that LiveView uses, and PostgreSQL is the database our app will use.
Create a new Phoenix liveview app.
In your terminal create a new Phoenix app with the following command.
mix phx.new todo_live_view
todo_live_view will be the app name. Feel free to use any name. Select Y
when you see the following prompt Fetch and install dependencies? [Yn]
to download and install dependencies.
In your terminal, cd
into the newly created app folder and configure the database with the following command mix ecto.create
. If you see the following warning:the :gettext compiler is nolonger required in your mix.exs.
, follow the instructions provided in the terminal to remove :gettext
entry and everything will be fine.
Run generated tests for generated code with mix test
and then run the app with mix phx.server
. The app should be accessible at localhost:4000.
Finished in 0.1 seconds (0.09s async, 0.09s sync)
3 tests, 0 failures
Create Todos context and a Todo schema.
To save our todo items in the database we need to have a table and a schema. We will generate them along with a context, a migration file, and tests with the help of a Phoenix generator. Run the following command:
mix phx.gen.context Todos Todo todos text:string completed:boolean
The first argument is the context module followed by the schema module and its plural name (used as the schema table name). The Todos
context will serve as an API boundary for the Todo
resource. You can find more about mix phx.gen.context
from the docs.
Migrate your database with the following command:
mix ecto.migrate
Bootstrap the pages.
Let us create a bare minimum of the files mentioned in the Application Structure section.
lib/todo_live_view_web/live/todos_live.ex
defmodule TodoLiveViewWeb.TodosLive do
use TodoLiveViewWeb, :live_view
alias TodoLiveView.Todos
alias TodoLiveView.Todos.Todo
def mount(_params, _session, socket) do
{:ok,
assign(socket, items: Todos.list_todos())
|> assign_todo()}
end
def assign_todo(socket) do
socket
|> assign(:todo, %Todo{})
end
end
A LiveView
is a simple module that requires two callbacks: mount/3 and render/1. If the render function is not defined, a template with the same name should be provided and ending in .html.heex.
Since we split code into LiveComponents, We will be using liveview as our source of truth. More can be found here.
lib/todo_live_view_web/live/todos_live.html.heex
<div>
<h1>Todos</h1>
<.live_component
module={TodoLiveViewWeb.FormComponent}
id="todo_input"
text_value={nil}
todo={@todo}
/>
<.live_component
module={TodoLiveViewWeb.TodosListComponent}
items={@items}
id="todos_list"
/>
</div>
When calling the live component with .live_component, you must always pass the module
and id
attributes. The id
will be available as an assign and it must be used to uniquely identify the component.
lib/todo_live_view_web/live/todos_list_component.ex
defmodule TodoLiveViewWeb.TodosListComponent do
use TodoLiveViewWeb, :live_component
end
The smallest LiveComponent only needs to define a render/1 function. In our case, we have a separate template file instead of a render function.
lib/todo_live_view_web/live/todos_list_component.html.heex
<div>
<ul>
<%= for item <- @items do %>
<li id={"item-#{item.id}"}>
<%= checkbox(
:item,
:completed,
value: item.completed
) %>
<p
class="todo_item"
>
<%= item.text %>
</p>
<button
class="delete_btn"
>
Delete
</button>
</li>
<% end %>
</ul>
</div>
lib/todo_live_view_web/live/form_component.ex
defmodule TodoLiveViewWeb.FormComponent do
use TodoLiveViewWeb, :live_component
alias TodoLiveView.Todos
@impl true
def update(%{todo: todo} = assigns, socket) do
changeset = Todos.change_todo(todo)
{:ok,
socket
|> assign(assigns)
|> assign(:changeset, changeset)}
end
end
We will be using a changeset in the form. More details can be found in the docs.
lib/todo_live_view_web/live/form_component.html.heex
<div>
<.form
let={f}
for={@changeset}
id="todo-form"
>
<%= label(f, :text) %>
<%= text_input(f, :text) %>
<%= error_tag(f, :text) %>
<%= submit("Add todo", phx_disable_with: "Adding...") %>
</.form>
</div>
The error_tag/2 Phoenix view helper function displays the form's errors for a given field on a changeset. You can find more on forms from here.
lib/todo_live_view_web/router.ex
live "/", TodosLive
We need to point to our liveview by changing get "/", PageController, :index
to the above line.
Our app should now look like the following:
A single test should be falling when we run mix test
. Delete test/todo_blog_web/controllers/page_controller_test.exs since we are no longer using PageController and all tests should pass.
Enable todo item creation.
Update lib/todo_live_view_web/live/form_component.ex with the following code:
.
.
.
alias TodoLiveView.Todos.Todo
@todos_topic "todos"
.
.
.
@impl true
def handle_event("validate", %{"todo" => todo_params}, socket) do
changeset =
socket.assigns.todo
|> Todos.change_todo(todo_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
@impl true
def handle_event("create_todo", %{"todo" => todo_params}, socket) do
save_todo(socket, todo_params)
end
defp save_todo(socket, todo_params) do
case Todos.create_todo(todo_params) do
{:ok, _todo} ->
socket =
assign(socket, items: Todos.list_todos())
|> assign(:changeset, Todos.change_todo(%Todo{}))
TodoLiveViewWeb.Endpoint.broadcast(@todos_topic, "todos_updated", socket.assigns)
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
When handling the validate event, we use Map.put(:action, :validate)
to add the validate action to the changeset, a signal that instructs Phoenix to display errors.
Since todos liveview is our source of truth, we need to inform it about the update of items after saving a todo item. We are broadcasting the update with Endpoint.broadcast
and we have the advantage of getting distributed updates out of the box. An alternative is to have the component send a message directly to the parent view.
Update lib/todo_live_view_web/live/form_component.html.heex to the following:
<div>
<.form
let={f}
for={@changeset}
id="todo-form"
phx-target={@myself}
phx-change="validate"
phx-submit="create_todo"
>
<%= label(f, :text) %>
<%= text_input(f, :text) %>
<%= error_tag(f, :text) %>
<%= submit("Add todo", phx_disable_with: "Adding...") %>
</.form>
</div>
phx-target={@myself}
indicates that events will be handled by the same component. create_todo
event fires when the user submits the form. validate
event will fire on input change.
Update lib/todo_live_view_web/live/todos_live.ex with the following changes:
.
.
.
@todos_topic "todos"
def mount(_params, _session, socket) do
if connected?(socket), do: TodoLiveViewWeb.Endpoint.subscribe(@todos_topic)
{:ok,
assign(socket, items: Todos.list_todos())
|> assign_todo()}
end
.
.
.
def handle_info(%{event: "todos_updated", payload: %{items: items}}, socket) do
{:noreply, assign(socket, items: items)}
end
We are subscribing to @todos_topic when the liveview is connected. We are handling todos_updated event by updating our items on the socket. That way, our todos list live component will be able to display the correct items on the screen.
Add the following styles to assets/css/app.css.
li {
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
}
li button.delete_btn {
height: unset;
line-height: unset;
background-color: #d11a2a;
}
p.todo_item {
cursor: pointer;
margin-bottom: 16px;
}
p.todo_item.completed {
text-decoration: line-through;
}
Add the following test in test/todo_live_view_web/live/todos_live_test.exs.
defmodule TodoLiveViewWeb.TodosLiveTest do
use TodoLiveViewWeb.ConnCase
import Phoenix.LiveViewTest
@create_todo_attrs %{text: "first item"}
@invalid_todo_attrs %{text: nil}
test "create todo", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert view
|> form("#todo-form", todo: @invalid_todo_attrs)
|> render_change() =~ "can't be blank"
view
|> form("#todo-form", todo: @create_todo_attrs)
|> render_submit()
assert render(view) =~ "first item"
end
end
All tests should pass after running mix test
.
...........
Finished in 0.5 seconds (0.2s async, 0.3s sync)
11 tests, 0 failures
Randomized with seed 146173
You should be able to add and view the added item as shown in the image below:
Toggle todo item.
Update lib/todo_live_view_web/live/todos_list_component.ex with the following changes:
.
.
.
alias TodoLiveView.Todos
@todos_topic "todos"
def handle_event("toggle_todo", %{"id" => id}, socket) do
todo = Todos.get_todo!(id)
Todos.update_todo(todo, %{completed: !todo.completed})
socket = assign(socket, items: Todos.list_todos())
TodoLiveViewWeb.Endpoint.broadcast(@todos_topic, "todos_updated", socket.assigns)
{:noreply, socket}
end
def item_completed?(item) do
if item.completed == true, do: "completed", else: ""
end
.
.
.
Nothing fancy here. We have a function that determines whether the item is completed and returns the appropriate CSS class. We are updating the todo item and broadcasting the updated list of items.
Update lib/todo_live_view_web/live/todos_list_component.html.heex to the following:
<div>
<ul>
<%= for item <- @items do %>
<li id={"item-#{item.id}"}>
<%= checkbox(
:item,
:completed,
phx_click: "toggle_todo",
phx_value_id: item.id,
phx_target: @myself,
value: item.completed
) %>
<p
class={"todo_item #{item_completed?(item)}"}
phx-click="toggle_todo"
phx-value-id={item.id}
phx-target={@myself}
>
<%= item.text %>
</p>
<button
class="delete_btn"
>
Delete
</button>
</li>
<% end %>
</ul>
</div>
We are toggling an item with both the checkbox click and a click on the item text. We are passing the id to the event handler with phx-value-id
.
Let's add a test. Update test/todo_live_view_web/live/todos_live_test.exs with the following:
.
.
.
alias TodoLiveView.Todos
.
.
.
test "toggle todo item", %{conn: conn} do
{:ok, todo} = Todos.create_todo(%{"text" => "first item"})
assert todo.completed == false
{:ok, view, _html} = live(conn, "/")
assert view |> element("#item_completed") |> render_click() =~ "completed"
updated_todo = Todos.get_todo!(todo.id)
assert updated_todo.completed == true
end
All tests should be passing and the functionality should be working.
............
Finished in 0.5 seconds (0.1s async, 0.3s sync)
12 tests, 0 failures
Randomized with seed 331510
Delete todo item.
Update lib/todo_live_view_web/live/todos_list_component.ex with the following:
.
.
.
def handle_event("delete_todo", %{"id" => id}, socket) do
todo = Todos.get_todo!(id)
Todos.delete_todo(todo)
socket = assign(socket, items: Todos.list_todos())
TodoLiveViewWeb.Endpoint.broadcast(@todos_topic, "todos_updated", socket.assigns)
{:noreply, socket}
end
.
.
.
Place handle_event functions next to each other.
Update the Delete button in lib/todo_live_view_web/live/todos_list_component.html.heex to the following:
.
.
.
<button
class="delete_btn"
phx-click="delete_todo"
phx-value-id={item.id}
phx-target={@myself}
>
Delete
</button>
Add a test. Update test/todo_live_view_web/live/todos_live_test.exs with the following:
.
.
.
test "delete todo item", %{conn: conn} do
{:ok, todo} = Todos.create_todo(%{"text" => "first item"})
assert todo.completed == false
{:ok, view, _html} = live(conn, "/")
view |> element("button", "Delete") |> render_click()
refute has_element?(view, "#item-#{todo.id}")
end
All tests should be passing and the functionality should be working.
.............
Finished in 0.5 seconds (0.1s async, 0.3s sync)
13 tests, 0 failures
Randomized with seed 468301
Add filters.
We are almost done with the tutorial. Update lib/todo_live_view_web/live/todos_live.ex to the following:
.
.
.
def handle_params(params, _url, socket) do
items = Todos.list_todos()
case params["filter_by"] do
"completed" ->
completed_items = Enum.filter(items, &(&1.completed == true))
{:noreply, assign(socket, items: completed_items)}
"active" ->
active_items = Enum.filter(items, &(&1.completed == false))
{:noreply, assign(socket, items: active_items)}
_ ->
{:noreply, assign(socket, items: items)}
end
end
This function will handle params from live patch
links. More details are to be provided soon. We are filtering and updating the socket
with filtered items.
Update lib/todo_live_view_web/live/todos_live.html.heex with the following:
.
.
.
<div>
Show:
<span>
<%= live_patch("All",
to: Routes.live_path(@socket, TodoLiveViewWeb.TodosLive, %{filter_by: "all"})
) %>
</span>
<span>
<%= live_patch("Active",
to: Routes.live_path(@socket, TodoLiveViewWeb.TodosLive, %{filter_by: "active"})
) %>
</span>
<span>
<%= live_patch("Completed",
to: Routes.live_path(@socket, TodoLiveViewWeb.TodosLive, %{filter_by: "completed"})
) %>
</span>
</div>
A live patch
link patches
the current live view - the link will change the URL in the browser with the help of JavaScript's push state navigation feature but it won't send a web request to reload the page. When a live patch link is cliked, handle_params/3 function will be invoked for the linked LiveView, followed by the render/1 function.
Add a test. Update test/todo_live_view_web/live/todos_live_test.exs with the following:
.
.
.
test "Filter todo items", %{conn: conn} do
{:ok, _first_todo} = Todos.create_todo(%{"text" => "first item"})
{:ok, _second_todo} = Todos.create_todo(%{"text" => "second item"})
{:ok, view, _html} = live(conn, "/")
# complete first item
assert view |> element("p", "first item") |> render_click() =~
"completed"
# completed first item should be visible
{:ok, view, _html} = live(conn, "/?filter_by=completed")
assert render(view) =~ "first item"
refute render(view) =~ "second item"
# active second item should be visible
{:ok, view, _html} = live(conn, "/?filter_by=active")
refute render(view) =~ "first item"
assert render(view) =~ "second item"
# All items should be visible
{:ok, view, _html} = live(conn, "/?filter_by=all")
assert render(view) =~ "first item"
assert render(view) =~ "second item"
end
All tests should be passing and the functionality should be working as shown in the first gif at the beginning of the tutorial.
..............
Finished in 0.5 seconds (0.2s async, 0.3s sync)
14 tests, 0 failures
Randomized with seed 807925
Congratulations! You have successfully built a functional todo list with Phoenix LiveView. I hope this tutorial has been informative and helpful in improving your coding skills. Don't stop here, though - keep experimenting and learning to enhance your knowledge and expertise.
The complete code is available on my Github account https://github.com/collinewait/todo_live_view.
Top comments (3)
It can't go down, saying:
error: undefined variable "f"
lib/todo_live_view_web/live/form_component.html.heex:3: TodoLiveViewWeb.FormComponent.render/1
...
I have same
@studentops @long-dev
The errors you're experiencing are probably due to the fact that this tutorial has been generated a few months ago, with Phoenix 1.6.14 and Liveview 0.17.5.
But if you launch the generator
mix phx.new todo_live_view
today (December 2023), the new application will be created with Phoenix 1.7.7 and LiveView 0.19.0.While the main concepts are still valid, some minor details have changed since then.
For example the error related to the checkbox is caused by the fact that today forms are rendered via livewiew components, e.g.:
<.input type="checkbox" ...
instead of<%= checkbox(...
I created a repo with the same application, slightly adapted to use Phoenix 1.7.7 and LiveView 0.19.0.
Check it out here:
github.com/csarnataro/todo_live_view