This example will walk you through creating a simple Todo List application to demonstrate forms and data manipulation with LiveView.
Step 1: Set up a new Phoenix project (if you haven't already)
Follow the same initial setup as in the counter example:
mix phx.new todo_app --live
cd todo_app
mix ecto.setup
Step 2: Create the Todo LiveView
Create a new file at lib/todo_app_web/live/todo_live.ex
:
defmodule TodoAppWeb.TodoLive do
# Import LiveView functionality
use TodoAppWeb, :live_view
# Define the initial state when the LiveView mounts
def mount(_params, _session, socket) do
# Set up initial state with an empty list of todos and a blank form
{:ok,
assign(socket,
todos: [], # Empty list to store our todos
new_todo: "", # Empty string for the form input
filter: :all # Filter state (all, active, completed)
)
}
end
# Handle form submission event
def handle_event("add_todo", %{"todo" => todo_text}, socket) do
# Skip adding empty todos
if String.trim(todo_text) == "" do
# Return without changing state
{:noreply, socket}
else
# Create a new todo item with a unique ID
new_todo = %{
id: System.unique_integer([:positive]), # Generate a unique ID
text: todo_text, # The todo text from the form
completed: false, # New todos start as not completed
editing: false # Not in editing mode initially
}
# Add the new todo to our list and clear the form
{:noreply,
socket
|> update(:todos, fn todos -> todos ++ [new_todo] end) # Append to list
|> assign(:new_todo, "") # Reset form input
}
end
end
# Handle checkbox toggle event
def handle_event("toggle", %{"id" => id}, socket) do
# Convert string ID to integer (from form params)
id = String.to_integer(id)
# Update the todo list by mapping through each item
updated_todos = Enum.map(socket.assigns.todos, fn todo ->
if todo.id == id do
# For the matching todo, toggle its completed state
Map.update!(todo, :completed, fn completed -> !completed end)
else
# For other todos, leave them unchanged
todo
end
end)
# Update the state with the modified todo list
{:noreply, assign(socket, todos: updated_todos)}
end
# Handle delete event
def handle_event("delete", %{"id" => id}, socket) do
# Convert string ID to integer
id = String.to_integer(id)
# Filter out the todo with the matching ID
updated_todos = Enum.reject(socket.assigns.todos, fn todo -> todo.id == id end)
# Update the state with the filtered todo list
{:noreply, assign(socket, todos: updated_todos)}
end
# Handle change in the new todo input field
def handle_event("form_change", %{"todo" => new_value}, socket) do
# Update the form input value as the user types
{:noreply, assign(socket, new_todo: new_value)}
end
# Handle filter change events
def handle_event("filter", %{"filter" => filter}, socket) do
# Convert string filter to atom
filter = String.to_existing_atom(filter)
# Update the filter state
{:noreply, assign(socket, filter: filter)}
end
# Helper function to filter todos based on current filter
defp filtered_todos(todos, filter) do
case filter do
:all -> todos # Show all todos
:active -> Enum.filter(todos, fn todo -> !todo.completed end) # Only uncompleted
:completed -> Enum.filter(todos, fn todo -> todo.completed end) # Only completed
end
end
# Render the LiveView template
def render(assigns) do
~H"""
<div class="todo-container">
<h1>Todo List</h1>
<!-- Form to add new todos -->
<form phx-submit="add_todo" class="add-form">
<!-- phx-change tracks input as it happens -->
<input
type="text"
name="todo"
placeholder="What needs to be done?"
value={@new_todo}
phx-change="form_change"
autofocus
class="todo-input"
/>
<button type="submit" class="add-button">Add</button>
</form>
<!-- Filter controls -->
<div class="filters">
<!-- phx-click sends events when buttons are clicked -->
<button
phx-click="filter"
phx-value-filter="all"
class={"filter-btn #{if @filter == :all, do: "active"}"}
>
All
</button>
<button
phx-click="filter"
phx-value-filter="active"
class={"filter-btn #{if @filter == :active, do: "active"}"}
>
Active
</button>
<button
phx-click="filter"
phx-value-filter="completed"
class={"filter-btn #{if @filter == :completed, do: "active"}"}
>
Completed
</button>
</div>
<!-- Todo list -->
<ul class="todo-list">
<!-- Loop through filtered todos -->
<%= for todo <- filtered_todos(@todos, @filter) do %>
<li class={"todo-item #{if todo.completed, do: "completed"}"}>
<div class="todo-content">
<!-- Toggle completion status -->
<input
type="checkbox"
phx-click="toggle"
phx-value-id={todo.id}
checked={todo.completed}
class="todo-checkbox"
/>
<!-- Todo text -->
<span class="todo-text"><%= todo.text %></span>
<!-- Delete button -->
<button
phx-click="delete"
phx-value-id={todo.id}
class="delete-btn"
>
×
</button>
</div>
</li>
<% end %>
</ul>
<!-- Counter at the bottom -->
<div class="todo-count">
<%= length(Enum.filter(@todos, fn todo -> !todo.completed end)) %> items left
</div>
</div>
"""
end
end
Step 3: Add the route for our Todo LiveView
Edit lib/todo_app_web/router.ex
:
defmodule TodoAppWeb.Router do
use TodoAppWeb, :router
# Default Phoenix pipelines (already included)
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {TodoAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
# Browser routes
scope "/", TodoAppWeb do
pipe_through :browser
# Route the root path to our TodoLive module
live "/", TodoLive
end
end
Step 4: Add CSS for the Todo App
Edit assets/css/app.css
:
/* Add this to the bottom of the file */
/* Container for the todo application */
.todo-container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
font-family: system-ui, sans-serif;
}
/* Form for adding new todos */
.add-form {
display: flex;
margin-bottom: 20px;
}
.todo-input {
flex-grow: 1;
padding: 10px;
font-size: 16px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
}
.add-button {
padding: 10px 15px;
background-color: #4a6da7;
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
}
.add-button:hover {
background-color: #2c4a7c;
}
/* Filter controls */
.filters {
display: flex;
margin-bottom: 15px;
gap: 10px;
}
.filter-btn {
padding: 5px 10px;
background-color: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.filter-btn.active {
background-color: #4a6da7;
color: white;
}
/* Todo list */
.todo-list {
list-style-type: none;
padding: 0;
margin: 0;
}
.todo-item {
padding: 10px;
border-bottom: 1px solid #eee;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-content {
display: flex;
align-items: center;
}
.todo-checkbox {
margin-right: 10px;
}
.todo-text {
flex-grow: 1;
}
.delete-btn {
background: none;
border: none;
color: #ff4d4d;
font-size: 18px;
cursor: pointer;
padding: 0 5px;
}
.delete-btn:hover {
color: #ff0000;
}
/* Counter at the bottom */
.todo-count {
margin-top: 15px;
color: #777;
font-size: 14px;
}
Step 5: Run the application
Start the Phoenix server:
mix phx.server
Visit http://localhost:4000
in your browser to see your Todo List application in action!
Understanding the LiveView Data Flow
-
Initial Load:
-
mount/3
initializes the state with an empty todo list and form -
render/1
generates the initial HTML with the form and empty list
-
-
Adding a Todo:
- User types in the input field, triggering the
form_change
event -
handle_event("form_change", ...)
updates thenew_todo
value in real-time - User submits the form, triggering the
add_todo
event -
handle_event("add_todo", ...)
creates a new todo and adds it to the list -
render/1
updates the DOM to show the new todo
- User types in the input field, triggering the
-
Toggling a Todo:
- User clicks a checkbox, triggering the
toggle
event -
handle_event("toggle", ...)
finds the todo by ID and toggles its completion status -
render/1
updates the DOM to reflect the changed status
- User clicks a checkbox, triggering the
-
Filtering Todos:
- User clicks a filter button, triggering the
filter
event -
handle_event("filter", ...)
updates the filter state -
filtered_todos/2
helper function filters the todos based on current filter -
render/1
updates the DOM to show only the filtered todos
- User clicks a filter button, triggering the
This demonstrates key LiveView concepts:
- Form handling with
phx-submit
andphx-change
- Passing values with
phx-value-*
attributes - Filtering and manipulating data in real-time
- Conditional CSS classes using the
#{if condition, do: "class"}
syntax
Next Steps
This Todo app demonstrates the basics of LiveView state management and event handling. You could extend it with:
- Persistence using Ecto
- User accounts
- Shared todo lists between users
- Due dates and priorities
- Breaking into smaller LiveComponents for better organization
Top comments (0)