DEV Community

Sospeter Mong'are
Sospeter Mong'are

Posted on

2

Building a Todo List App with Elixir and Phoenix LiveView

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Run the application

Start the Phoenix server:

mix phx.server
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:4000 in your browser to see your Todo List application in action!

Understanding the LiveView Data Flow

  1. 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
  2. Adding a Todo:

    • User types in the input field, triggering the form_change event
    • handle_event("form_change", ...) updates the new_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
  3. 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
  4. 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

This demonstrates key LiveView concepts:

  • Form handling with phx-submit and phx-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:

  1. Persistence using Ecto
  2. User accounts
  3. Shared todo lists between users
  4. Due dates and priorities
  5. Breaking into smaller LiveComponents for better organization

Top comments (0)