Dependent Selects

Three dependent selects for booking a cart: select a kingdom, a region and a town. Each select decides what the next one offers, without the update being declared by hand. The address bar follows each change, so a journey can be opened from a link.

Book a cart

Nowhere to go yet.

# frozen_string_literal: true

# Booking a cart: kingdom, then region, then town. The three states hold what
# the visitor last picked at each level. What a select offers, and whether a
# pick still counts, are derived from the level above, so no action ever
# clears anything.
class Recipes::Selects::SelectsChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Place = Recipes::Selects::Place
  Choice = Recipes::Selects::Choice
  VIEWS = "app/recipes/selects"

  def build_graph
    # The tab subscribes with the journey its URL named, so a reloaded or
    # shared link opens with all three selects filled in.
    start = Choice.from(params)
    @kingdom = Hibiki::State.new(start.kingdom)
    @region = Hibiki::State.new(start.region)
    @town = Hibiki::State.new(start.town)
    @booked = Hibiki::State.new(false)

    # A pick counts only while the list above still offers it. Change the
    # kingdom and the region is no longer on the list, so the towns empty
    # and the town stops counting too.
    @regions = Hibiki::Derived.new { Place.under(@kingdom.value).options }
    region = Hibiki::Derived.new { offered(@regions.value, @region.value) }
    @towns = Hibiki::Derived.new { Place.under(region.value).options }
    town = Hibiki::Derived.new { offered(@towns.value, @town.value) }
    @choice = Hibiki::Derived.new { Choice.new(kingdom: @kingdom.value, region: region.value, town: town.value) }

    # A select is sent again when its options change, not when its own value
    # does: `peek` reads the selection without subscribing to it, so the
    # select someone is using is never replaced under them.
    Hibiki::Effect.new do
      broadcast_replace target: "selects_region", partial: "#{VIEWS}/level",
                        locals: { level: :region, options: @regions.value, selected: @choice.peek.region }
    end

    Hibiki::Effect.new do
      broadcast_replace target: "selects_town", partial: "#{VIEWS}/level",
                        locals: { level: :town, options: @towns.value, selected: @choice.peek.town }
    end

    Hibiki::Effect.new do
      broadcast_replace target: "selects_summary", partial: "#{VIEWS}/summary",
                        locals: { places: @choice.value.places, booked: @booked.value }
    end

    transmit_url { urls.app_recipe_path("selects", **@choice.value.to_query) }
  end

  def set_kingdom(data) = pick(@kingdom, data["kingdom"], Place.kingdoms.options)

  def set_region(data) = pick(@region, data["region"], @regions.value)

  def set_town(data) = pick(@town, data["town"], @towns.value)

  def book = @booked.value = @choice.value.town.present?

  private

  # Whatever the browser sends, only something on the list is kept.
  def pick(state, slug, options)
    state.value = offered(options, slug)
    @booked.value = false
  end

  def offered(options, slug) = options.rassoc(slug)&.last

  def urls = Rails.application.routes.url_helpers
end

The form above books a cart with three selects: a kingdom, a region in it and a town in that region. Each select offers only what sits under the one before it, and changing the kingdom empties the two below, along with the fare and the button. The address bar follows, so a journey can be opened from a link. Stock Rails leaves all of this to hand-written JavaScript. Here each level is written once, as what it depends on, and the emptying takes care of itself.

How it fits together

The picks live in a channel: the server side of an Action Cable connection, which stays alive while the page is open. A state holds a value and remembers who read it. Here @kingdom, @region and @town are states, one for what the visitor last picked at each level. The channel's public methods are its actions, and set_kingdom, set_region and set_town each write one of them.

A derived is a value worked out from other values, and worked out again when one of them changes. The option lists are deriveds: @regions reads @kingdom and asks the database for the regions under it, and @towns does the same under the region.

An effect is a block of code that runs once, and runs again whenever a value it read has changed. There are three: one for the region select, one for the town select and one for the summary, each rendering its partial and sending it to the page.

Reactivity resets the lower selects

The usual way to write a chain of selects is a reset: when the kingdom changes, set the region and the town to nothing. That works, but every level has to know about all the levels below it. Here the chain is written as what each level depends on:

# in build_graph
@regions = Hibiki::Derived.new { Place.under(@kingdom.value).options }
region = Hibiki::Derived.new { offered(@regions.value, @region.value) }
@towns = Hibiki::Derived.new { Place.under(region.value).options }
town = Hibiki::Derived.new { offered(@towns.value, @town.value) }

# the whole of the kingdom action
def set_kingdom(data) = pick(@kingdom, data["kingdom"], Place.kingdoms.options)

The whole kingdom action is the last line. This is what happens when you change the kingdom from Northmarch to Sunmere:

  1. @regions read @kingdom, so it is now the list of Sunmere's regions.
  2. Greyfen is not on that list, so region becomes nil.
  3. @towns read region, and under nothing there are no towns, so town becomes nil too.
  4. The effects read those, so each sends its part again: the region select with new options, the town select empty and disabled, and the summary with the kingdom alone.

Go back to Northmarch and the old region and town return, because they are on the list again; the states held them all along. The same check guards against bad input, since an action keeps a value only if the list offers it.

The select in use is never replaced

Replacing a select while someone is using it takes the focus away, which keyboard users notice at once. So a select is sent again only when its options change, and never because of its own value:

Hibiki::Effect.new do
  broadcast_replace target: "selects_region", partial: "#{VIEWS}/level",
                    locals: { level: :region,
                              options: @regions.value,          # subscribes: new options, new select
                              selected: @choice.peek.region }   # does not: picking a region sends nothing here
end

peek reads a value without subscribing to it. The effect reads @regions, so new options mean a new select, and peeks at the chosen region, so choosing one sends nothing here. Choosing a region re-runs the town select and the summary, and leaves the region select alone.

A link opens all three levels

A choice that lives in a socket is gone when the page reloads, unless the page writes it down somewhere. Here it goes in the URL: the transmit_url block reads @choice and sends a new path whenever it changes. When that link is opened, the page and the channel both start from Choice.from, which walks down from the kingdom and keeps a level only if the level above offers it. A link that names a town in the wrong region opens with the kingdom alone.

Adding a fourth level

Say each town has inns. The new level is a state, two deriveds, an effect and an action, plus inn in Choice and a fourth render in the page:

# in build_graph
@inn = Hibiki::State.new(start.inn)
@inns = Hibiki::Derived.new { Place.under(town.value).options }
inn = Hibiki::Derived.new { offered(@inns.value, @inn.value) }

Hibiki::Effect.new do
  broadcast_replace target: "selects_inn", partial: "#{VIEWS}/level",
                    locals: { level: :inn, options: @inns.value, selected: @choice.peek.inn }
end

def set_inn(data) = pick(@inn, data["inn"], @inns.value)

Nothing above it was edited. set_kingdom does not learn that inns exist, and changing the kingdom still empties them, through the same chain.

The same feature in stock Rails

The usual stock Rails build is a JSON endpoint for each dependent select and a Stimulus controller that fetches the list and rebuilds the options. Here is a sketch of it. It is not run here, and it leaves out the model, which would be the same in both versions.

import { Controller } from "@hotwired/stimulus"

// Everything a chain of selects needs that Rails does not do for you.
export default class extends Controller {
  static targets = ["kingdom", "region", "town", "summary", "button"]
  static values = { regionsUrl: String, townsUrl: String }

  async kingdomChanged() {
    // The reset, by hand. Every level below the one that changed has to be
    // emptied here, so a fourth level means editing this method too.
    this.fill(this.townTarget, [], "Pick a region first")
    this.fill(this.regionTarget, await this.load(this.regionsUrlValue, this.kingdomTarget.value), "Choose a region")
    this.refresh()
  }

  async regionChanged() {
    this.fill(this.townTarget, await this.load(this.townsUrlValue, this.regionTarget.value), "Choose a town")
    this.refresh()
  }

  townChanged() {
    this.refresh()
  }

  // A slow reply to an earlier choice can arrive after a later one and
  // fill the select with the wrong list. Guarding against that is more
  // code, left out here.
  async load(url, slug) {
    if (!slug) return []
    const response = await fetch(url.replace(":slug", slug), { headers: { Accept: "application/json" } })
    return response.json()
  }

  // Rebuilds a select's options, which the server rendered the first time.
  fill(select, places, prompt) {
    select.replaceChildren(new Option(prompt, ""))
    for (const place of places) {
      const option = new Option(place.name, place.slug)
      if (place.fare) option.dataset.fare = place.fare
      select.add(option)
    }
    select.disabled = places.length === 0
  }

  // The summary, the button and the address bar: each one is a line that
  // has to be called from every method above.
  refresh() {
    const chosen = [this.kingdomTarget, this.regionTarget, this.townTarget]
      .map((select) => select.selectedOptions[0])
      .filter((option) => option?.value)

    const names = chosen.map((option) => option.text).join(" › ")
    const fare = this.townTarget.selectedOptions[0]?.dataset.fare
    this.summaryTarget.textContent = names ? `${names}${fare ? ` ${fare} gp` : ""}` : "Nowhere to go yet."
    this.buttonTarget.disabled = !this.townTarget.value

    const query = new URLSearchParams(chosen.map((option) => [option.parentElement.name, option.value]))
    history.replaceState({}, "", `${this.element.action}?${query}`)
  }
}

Each piece is small. The options are rendered twice, though: once in ERB for the first load and once in JavaScript after that, and the order in which things empty is spelled out by hand. The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
A select offers what sits under the one above A route and a JSON action per level, a fetch, and JavaScript that builds the options A derived and an effect; the partial that drew the select draws it again
Emptying what is below a changed select Each change handler empties every level below it Nothing; a pick counts only while it is offered
Disabling a select with nothing to offer A line of JavaScript, and the same rule in ERB One disabled: in the partial
The summary and the button Targets, a method every handler calls, and the fare passed through data- attributes One effect and its partial
The URL follows the choice JavaScript that collects the selects and calls replaceState One transmit_url block
Two quick changes in a row Replies can arrive out of order; guarding against it is more JavaScript Actions run one at a time, in the order sent
Adding a level Route, action, target, handler, and an edit to each handler above State, two deriveds, effect, action
Languages the logic lives in Ruby and JavaScript Ruby

Stock Rails has a second way with no JSON: submit the whole form on every change and replace it with a Turbo Frame or a stream. That removes the option-building JavaScript, but every change sends every field, the focus has to be kept by morphing, and the reset is still written by hand, now on the server. Here each action writes one pick, and what the selects offer follows from there.