Todo List

A todo list kept as one state on the server. Add tasks, tick them off and filter them, while the header and the list renders again only when something they read has changed. Rows slide in and out with no JavaScript of the page's own.

Todo List

Saturday, October 24

My tasks

1 of 3 done

33%

  • Read the Hibiki recipes
  • Build a reactive island
  • Ship it before lunch
2 left
# frozen_string_literal: true

# A todo list held entirely in signals: no table, one list per browser tab.
# Actions only write @items or @filter; the two effects morph the header
# summary and the list whenever what they read changes.
class Recipes::Todo::TodoChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Item = Data.define(:id, :title, :done)

  FILTERS = %w[all active done].freeze

  SEED = [
    Item.new(id: 1, title: "Read the Hibiki recipes", done: true),
    Item.new(id: 2, title: "Build a reactive island", done: false),
    Item.new(id: 3, title: "Ship it before lunch", done: false)
  ].freeze

  def build_graph
    @items = Hibiki::State.new(SEED)
    @filter = Hibiki::State.new("all")
    @next_id = SEED.size + 1

    visible = Hibiki::Derived.new do
      case @filter.value
      when "active" then @items.value.reject(&:done)
      when "done" then @items.value.select(&:done)
      else @items.value
      end
    end

    Hibiki::Effect.new do
      broadcast_morph target: "todo_summary",
                      partial: "app/recipes/todo/summary",
                      locals: { items: @items.value }
    end

    Hibiki::Effect.new do
      broadcast_morph target: "todo_list",
                      partial: "app/recipes/todo/list",
                      locals: { items: @items.value, visible: visible.value, filter: @filter.value }
    end
  end

  def add(data)
    title = data["title"].to_s.strip.first(120)
    return if title.empty?

    @items.value = @items.value + [ Item.new(id: @next_id, title:, done: false) ]
    @next_id += 1
  end

  def toggle(data)
    id = Integer(data["id"])
    @items.value = @items.value.map { it.id == id ? it.with(done: !it.done) : it }
  end

  def remove(data)
    id = Integer(data["id"])
    @items.value = @items.value.reject { it.id == id }
  end

  def clear_done = @items.value = @items.value.reject(&:done)

  def set_filter(data)
    @filter.value = data["filter"] if FILTERS.include?(data["filter"])
  end
end

The entire todo list is Ruby in a channel. The list is one state, and two effects each look after their own part of the card. The list is not backed by a database table here, but can easily be adjusted to do so.

How it fits together

The list lives in a channel: the server side of an Action Cable connection, an object that stays alive while the page is open and answers messages from it. Inside it, a state holds a value and remembers who read it. Here @items is a state holding the whole list: an array of rows, each a small Data object with an id, a title and whether it is done. A second state, @filter, holds the name of the filter in use. A derived is a value worked out from other values, and worked out again when one of them changes. Here visible is a derived that reads both states and returns the rows the filter lets through.

An effect is a block of code that runs once, and runs again whenever a value it read has changed. This channel has two. The first reads @items and renders the header summary. The second reads @items, visible and @filter and renders the list. Each sends its HTML to the card with broadcast_morph, which updates its part of the card in place.

The channel's public methods are its actions, the methods the page may call. Here there are five, and each one writes a state. This is what happens when you tick a row:

  1. The button sends toggle with the row's id.
  2. toggle builds a new array in which that row has done flipped, and assigns it to @items.
  3. visible read @items, so it is worked out again.
  4. Both effects read @items, so both run again: the header shows one more done and the bar fills, and the row shows its tick.

Now press a filter. set_filter writes @filter, visible is worked out again, and the list effect runs. The header effect read neither of them, so it stays quiet. We never wrote down which effect a filter change belongs to. Whatever an effect read on its last run is what it depends on.

The island on the page

The part of a page that talks to a channel is an island: one ordinary HTML element with the channel's controls and output inside it. Here _todo.html.erb is the island. The island helper marks its root element for TodoChannel and adds the Turbo stream the effects broadcast on. Inside it, the controls send their actions with on:

  • The form carries on(:add, event: :submit), so submitting it sends its title field to add. The field then clears, ready for the next task.
  • Each row's buttons carry with:, so on(:toggle, with: { id: item.id }) tells the channel which row was meant. The filter buttons send their name the same way.

Rows that slide in and out

A row that enters can animate with CSS alone. A row that leaves cannot, because the swap removes it before any transition could start. The hibiki-rails/motion module, imported once in application.js, holds a swap back until any element marked data-motion that is about to leave has finished its transition. The transition itself is Tailwind on the row: starting: for the entrance and data-motion-leaving: for the exit.

import "hibiki-rails/motion"

Motion is also why the list is sent with broadcast_morph. A replace would put in every row afresh, and every row would play its entrance again. A morph keeps the rows whose ids still match, so a new row is the only insertion, and the header's bar can slide from its old width to the new one. The Leave and Enter Transitions recipe takes motion further.

The actions here only write the list; the two effects take care of the card from there. The Sign Up recipe uses the same arrangement for a form that checks itself as you type.