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

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (0)

The best way to debug slow web pages cover image

The best way to debug slow web pages

Tools like Page Speed Insights and Google Lighthouse are great for providing advice for front end performance issues. But what these tools can’t do, is evaluate performance across your entire stack of distributed services and applications.

Watch video

👋 Kindness is contagious

If this post resonated with you, feel free to hit ❤️ or leave a quick comment to share your thoughts!

Okay