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"""
    todo-container">
      Todo List

      
      add_todo" class="add-form">
        
        text" 
          name="todo" 
          placeholder="What needs to be done?" 
          value={@new_todo} 
          phx-change="form_change"
          autofocus
          class="todo-input"
        />
        submit" class="add-button">Add
      

      
      filters">
        
        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


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 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



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



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:
Persistence using Ecto
User accounts
Shared todo lists between users
Due dates and priorities
Breaking into smaller LiveComponents for better organization