Live Search

Search, filter, sort and page through a general store's shelves. The list and the count follow each change without being told to. The address bar also follows, so a search survives a reload and can be shared.

General Store

64 items

Everything on the shelves.
  • Amulet of Kindling Lv 20 800 gp
  • Antidote Lv 5 60 gp
  • Black Iron Axe Lv 25 880 gp
  • Bowl of Stew Lv 1 22 gp
  • Bronze Dagger Lv 1 20 gp
  • Bronze Pickaxe Lv 1 25 gp
  • Cabbage Lv 1 1 gp
  • Chain Hauberk Lv 25 1,100 gp
# frozen_string_literal: true

# Live search over the general store. The filters are five states; the count,
# the page of rows and the pager are derived from them. Actions only write
# state, and never say which part of the page should change.
class Recipes::Search::SearchChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Item = Recipes::Search::Item
  Filters = Recipes::Search::Filters
  VIEWS = "app/recipes/search"

  def build_graph
    # The tab subscribes with the filters its URL named, so a reloaded or
    # shared link starts where it left off.
    start = Filters.from(params)
    @q = Hibiki::State.new(start.q)
    @kind = Hibiki::State.new(start.kind)
    @max_level = Hibiki::State.new(start.max_level)
    @sort = Hibiki::State.new(start.sort)
    @page = Hibiki::State.new(start.page)
    @cleared = Hibiki::State.new(0)

    count = Hibiki::Derived.new { matching.count }
    pages = Hibiki::Derived.new { Item.pages(count.value) }
    page = Hibiki::Derived.new { @page.value.clamp(1, pages.value) }
    rows = Hibiki::Derived.new(equals: Hibiki::Rails.record_equals) do
      matching.sorted(@sort.value).on_page(page.value).to_a
    end

    Hibiki::Effect.new do
      broadcast_morph target: "search_results", partial: "#{VIEWS}/results", locals: { items: rows.value }
    end

    Hibiki::Effect.new do
      broadcast_replace target: "search_pager", partial: "#{VIEWS}/pager",
                        locals: { page: page.value, pages: pages.value }
    end

    Hibiki::Effect.new do
      broadcast_replace target: "search_status", partial: "#{VIEWS}/status",
                        locals: { q: @q.value, kind: @kind.value, max_level: @max_level.value }
    end

    # The controls are the visitor's to change, so the channel leaves them
    # alone, except to empty them after Clear.
    Hibiki::Effect.new do
      next unless @cleared.value.positive?

      broadcast_replace target: "search_form", partial: "#{VIEWS}/controls", locals: { filters: Filters.new }
    end

    transmit_value(:search_count) { "#{count.value} #{"item".pluralize(count.value)}" }
    transmit_value(:search_max_level) { @max_level.value }

    transmit_url do
      filters = Filters.new(q: @q.value, kind: @kind.value, max_level: @max_level.value,
                            sort: @sort.value, page: page.value)
      urls.app_recipe_path("search", **filters.to_query)
    end
  end

  def search(data)
    @q.value = data["q"].to_s.first(60)
    @page.value = 1
  end

  def set_kind(data)
    return unless data["kind"] == "all" || Item::KINDS.include?(data["kind"])

    @kind.value = data["kind"]
    @page.value = 1
  end

  def set_max_level(data)
    @max_level.value = data["max_level"].to_i.clamp(1, Item::MAX_LEVEL)
    @page.value = 1
  end

  def set_sort(data)
    @sort.value = data["sort"] if Item::SORTS.key?(data["sort"])
  end

  def go(data) = @page.value = data["page"].to_i

  def clear
    defaults = Filters.new
    @q.value = defaults.q
    @kind.value = defaults.kind
    @max_level.value = defaults.max_level
    @sort.value = defaults.sort
    @page.value = defaults.page
    @cleared.value += 1
  end

  private

  # The filtered shelves. Whatever calls this reads the three filter states,
  # so it runs again when one of them changes.
  def matching = Item.matching(q: @q.value, kind: @kind.value, max_level: @max_level.value)

  def urls = Rails.application.routes.url_helpers
end

The form above shows a live search: one that can be very tedious to achieve with stock Rails. The address bar follows too, so the search survives a page reload and can be shared between users. Each part of the search reads the filters it shows and is rendered again when one of them changes. We didn't have to map everything out by hand.

How it fits together

The filters live 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. A state holds a value and remembers who read it. Here each filter is a state, @q, @kind and so on, and the page number is one more. The channel's public methods are its actions, the methods the page may call. Each one writes a filter or two and returns.

A derived is a value worked out from other values, and worked out again when one of them changes. The query is one private method, matching, which reads the three states that decide what is on the shelves. Here count and rows are deriveds that call it, and pages and page follow from the count.

An effect is a block of code that runs once, and runs again whenever a value it read has changed. The channel has one for the list, one for the pager and one for the status line. Each reads what it shows, renders its partial and sends it to the page. The count in the heading and the number beside the slider go as text, with transmit_value. This is what happens when you type bronze:

  1. The search box sends search with what was typed, once you pause for 250 ms.
  2. search writes @q and sets @page back to 1.
  3. count and rows read @q through matching, so they are worked out again, and pages and page after them.
  4. Each effect read one of those, or @q itself, so each renders its part again and sends it. The count in the heading goes with them.

Now change the sort order. Only rows read @sort, so the list is the one part sent. A search that matches the same eight rows as before sends no list at all.

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 _search.html.erb is the island, and each control sends its action with on, such as on(:search, event: :input) on the search box. The controls sit outside every fragment the channel renders, so a reply never replaces the box you are typing in. The one time the channel does render them is after Clear filters: clear adds one to a counter, and an effect that read the counter sends the form back with every field at its default.

The address bar keeps up

A search that lives in a socket is gone when the page reloads, unless the page writes it down somewhere. Here it goes in the URL, so a reload or a shared link lands on the same search. With Hibiki, you can do this with a single block in the channel.

# in build_graph
transmit_url do
  filters = Filters.new(q: @q.value, kind: @kind.value, max_level: @max_level.value,
                        sort: @sort.value, page: page.value)
  urls.app_recipe_path("search", **filters.to_query)
end

transmit_url is one more reader of the filter states. When the path it works out changes, it sends the path, and the client puts it in the address bar with history.replaceState, so Back still leaves the page. Filters is a small value object holding what a visitor is searching for. Its to_query leaves out anything at its default, which keeps a plain search at a plain URL.

The other direction is ordinary Rails. The page reads its query string into a Filters, renders the shelves from it, and hands it to island as params:, so the channel starts from the same values. Filters.from checks each one, since it came from the browser.

Adding one more filter

The level slider was added last. It took a state, an action that writes it, one more argument to the query, and the input (and one more field in Filters, to carry it in the URL):

# in build_graph
@max_level = Hibiki::State.new(start.max_level)

def set_max_level(data)
  @max_level.value = data["max_level"].to_i.clamp(1, Item::MAX_LEVEL)
  @page.value = 1
end

def matching = Item.matching(q: @q.value, kind: @kind.value, max_level: @max_level.value)

No effect was edited. The count, the list and the pager already read matching, so they followed the new filter without being told about it.

The same feature in stock Rails

Rails can build this page with Turbo Streams and Stimulus, and many apps do. Here is a sketch of what that takes. It is not run here, and it leaves out the partials, which would be the same in both versions.

# A sketch of the stock Rails version. It is not part of this app.
class ItemsController < ApplicationController
  def index
    # Nothing is remembered between requests, so every form and every pager
    # link has to send all of this back, and a new filter joins this list.
    @filters = params.permit(:q, :kind, :max_level, :sort, :page).to_h

    scope = Item.matching(
      q: @filters.fetch("q", ""),
      kind: @filters.fetch("kind", "all"),
      max_level: @filters.fetch("max_level", Item::MAX_LEVEL).to_i
    )

    # One instance variable for each part of the page that shows a result.
    @count = scope.count
    @pages = Item.pages(@count)
    @page = @filters.fetch("page", 1).to_i.clamp(1, @pages)
    @items = scope.sorted(@filters.fetch("sort", "name")).on_page(@page)

    respond_to do |format|
      format.html           # first load, reload, shared link
      format.turbo_stream   # every search after that
    end
  end
end

Nothing in it is hard. Every live behaviour is wired by hand, though, either as a line in the stream template that names what to replace, or as a Stimulus method for what a stream cannot do. The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
The list, pager, status line and count follow a search Four lines in the stream template, four ids, four instance variables, kept in step by hand Each effect reads what it shows; nothing lists them
Searching as you type A Stimulus method, wired to each field with data-action event: :input on the field
The number beside the slider A Stimulus target and a line of JavaScript One transmit_value
The URL follows the search JavaScript after each submit and each pager click; defaults stripped in JS to match Ruby One transmit_url block
Remembering the filters between gestures Every form and link sends all of them back They are states in the channel
Adding a filter Form field and its data-action, permit list, query State, action, query, input
Adding a part that shows the results Instance variable, id, stream template line, partial One effect and its partial
Languages the logic lives in Ruby and JavaScript Ruby

A Turbo Frame around the results would save the stream template, but only while everything that changes sits inside one box. Here the count is in the heading and the controls must not be replaced mid-keystroke, which is what pushes a real page to streams. In the channel, the actions write the filters, and the effects take care of the page from there.