Cross-field Validation

A form for booking a room at the inn. A message under a field can follow another field: pick a bigger room and the note about too many guests disappears. The Save button waits until the form is both valid and changed. The rules are the model's own validations, written once in Ruby.

A room at the inn

Guests of the inn

  1. Nobody has signed the ledger today.
  2.  
  3.  
  4.  
  5.  

Nothing to save yet.

# frozen_string_literal: true

# A booking at the inn. The form holds the fields as signals and asks the
# model's validations what is wrong with them. Nothing here names a rule, or
# says which message belongs to which keystroke.
class Recipes::Validation::ValidationChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Booking = Recipes::Validation::Booking
  BookingForm = Recipes::Validation::BookingForm
  VIEWS = "app/recipes/validation"

  def build_graph
    @form = BookingForm.from(Booking.start)
    @resets = Hibiki::State.new(0)
    @saves = Hibiki::State.new(0)

    # One line of text under each field, sent only when the text changes. A
    # message that depends on two fields follows both.
    BookingForm.hibiki_attributes.each do |field|
      transmit_value(:"validation_error_#{field}") { @form.error_for(field) }
    end

    # The deposit tick comes and goes with the length of the stay. `paint`
    # reads the tick's own value without subscribing to it.
    Hibiki::Effect.new do
      @form.nights
      paint :deposit
    end

    # Save, Reset and the line beside them read `ready?` and `dirty?`.
    Hibiki::Effect.new do
      broadcast_morph target: "validation_actions", partial: "#{VIEWS}/actions", locals: { form: @form }
    end

    # The ledger is read again after every save, here or in another tab.
    # Its lines are plain values, so a save that changes none of the five
    # sends nothing.
    ledger = Hibiki::Derived.new do
      @saves.value
      Booking.ledger
    end

    Hibiki::Effect.new do
      broadcast_morph target: "validation_ledger", partial: "#{VIEWS}/ledger",
                      locals: { entries: ledger.value, mine: @form.record.id }
    end

    # The fields are the visitor's to change, so the channel leaves them
    # alone, except to put them back after Reset.
    Hibiki::Effect.new do
      next unless @resets.value.positive?

      paint :fields
    end
  end

  def set_field(data)
    name = data["field"].to_s.to_sym
    return unless BookingForm.hibiki_attributes.include?(name)

    value = data[name.to_s]
    @form.public_send(:"#{name}=", value.is_a?(String) ? value.first(60) : value)
  end

  def save
    return unless @form.ready?

    created = !@form.persisted?
    return unless @form.commit

    @saves.value += 1
    Booking.prune! if created
  end

  # Back to the booking as it was last saved, or to a new one.
  def reset
    @form.hydrate(saved || Booking.start)
    @resets.value += 1
  end

  private

  def subscribed
    super
    return if subscription_rejected?

    # A booking saved in another tab. The block runs on a cable thread, so
    # it hops onto the graph thread before touching a signal.
    stream_from Booking::CHANGED do
      graph_actor&.post { @saves.value += 1 }
    end
  end

  def saved = (Booking.find_by(id: @form.record.id) if @form.persisted?)

  # Renders one of the form's partials without subscribing to what it reads.
  def paint(name)
    Hibiki.untrack do
      broadcast_replace target: "validation_#{name}", partial: "#{VIEWS}/#{name}", locals: { form: @form }
    end
  end
end

The form above books a room at the inn: a name, the days you arrive and leave, a room and how many guests. Type 3 into the guests field, and you can see a message appear under it, since the twin room sleeps only two. Pick the family room in the select and the message goes, though you never touched the guests. Stay more than three nights and a deposit tick appears. The Save button enables only once the form is both valid and different from what was last saved. Keeping messages like these in step is tedious and often creates intractably complex code in stock Rails. You may be tempted to do these in JavaScript, which means the rules are then written twice. Here the rules are the model's own validations, written once in Ruby, and each message follows whichever fields its rule reads.

How it fits together

The booking lives in a channel: the server side of an Action Cable connection, an object that stays alive while the page is open. A state holds a value and remembers who read it. Here the states are the fields of a reactive form, BookingForm. One line, reactive_attributes, names the booking's columns and makes a state of each. The channel has one action for the fields, set_field, which writes the one that changed.

A derived is a value worked out from other values, and worked out again when one of them changes. Here draft is a derived that builds a booking from every field, one that is never saved, and live_errors is a derived that asks the draft what is wrong with it. valid? and nights follow from those.

An effect is a block of code that runs once, and runs again whenever a value it read has changed. The message under each field is sent by transmit_value, an effect that sends a piece of text, and sends it only when the text differs from last time. One more effect morphs the buttons and the line beside them, and another replaces the deposit tick. This is what happens when you type 3 into the guests field:

  1. The field calls set_field, which writes guests and returns.
  2. draft read guests, so a new draft is built, and live_errors read the draft, so the validations run again. The guests message now says the twin room sleeps 2.
  3. The transmit_value for the guests field read that message, so it sends the new text. The other messages come out as they were, so nothing is sent for them.
  4. The buttons effect read dirty?, which is now true, so it sends the buttons again, with Reset beside them and a note that the form is not ready.

Now pick the family room. The draft is built again, the guests message comes out empty, and the same transmit_value sends the empty text. The channel never says that the guests message depends on the room. Whatever a block read on its last run is what it depends on, and the draft reads every field.

The rules are written once

The rules are ordinary validations on the model, the same ones that guard a save. The form has no rules of its own: it builds a draft from its fields and asks the model what is wrong with it.

# app/models/recipes/validation/booking.rb
validates :name, presence: { message: "The ledger needs a name." }
validate :leaves_after_arriving, :guests_fit_the_room, :deposit_for_a_long_stay

def guests_fit_the_room
  return unless room_type && guests.to_i > room_type.beds

  errors.add(:guests, "The #{room_type.name.downcase} sleeps #{room_type.beds}.")
end

# app/forms/recipes/validation/booking_form.rb
derived(:draft)       { Booking.new(**to_h) }       # reads every field
derived(:live_errors) { draft.tap(&:validate).errors.to_hash.transform_values(&:first) }

# app/channels/recipes/validation/validation_channel.rb
transmit_value(:validation_error_guests) { @form.error_for(:guests) }

guests_fit_the_room reads the guests and the room, so the message under the guests field follows both. A rule added to the model tomorrow shows up under its field with no other edit. Note that no rule here reads the database, so validating the draft on every keystroke costs no query. A rule that does, such as a uniqueness check, is better left to the save: a failed save copies the model's errors into the form, and error_for shows those first.

Save knows whether anything changed

A Save button that is always enabled leaves the visitor to remember whether they changed anything. Here the form remembers for them. A reactive form keeps a snapshot of the fields as they were last loaded or saved, and dirty? is a derived that compares the fields with it. Type a name and delete it again, and the form is clean once more.

# ReactiveForm keeps a snapshot of the fields as they were last loaded or
# saved. `dirty?` is a derived that compares the fields with it.
derived(:valid?) { live_errors.empty? }
derived(:ready?) { dirty? && valid? }

# An untouched form says nothing about its empty fields.
def error_for(name) = super || (live_errors[name.to_sym] if dirty?)

# The channel. `commit` runs the model's validations once more, saves, and
# takes a new snapshot, so `dirty?` is false again.
def save
  return unless @form.ready?

  created = !@form.persisted?
  Booking.prune! if @form.commit && created
end

ready? is what the button waits for: changed and valid. An untouched form says nothing about its empty fields, because error_for holds a message back until the form is dirty. commit runs the validations once more, saves, and takes a new snapshot, so the button then reads Save changes and waits for the next valid change. Reset loads the saved booking back into the form, or a new one, and sends the fields once more.

A field that comes and goes

The deposit tick is on the page only for a stay of more than three nights, so it is a fragment of its own. Its effect subscribes to the number of nights alone, and ticking the box does not send the box back:

Hibiki::Effect.new do
  @form.nights      # subscribes: a different length of stay, a new fragment
  paint :deposit    # does not: ticking the box sends nothing here
end

def paint(name)
  Hibiki.untrack do
    broadcast_replace target: "validation_#{name}", partial: "#{VIEWS}/#{name}", locals: { form: @form }
  end
end

paint renders the fragment inside Hibiki.untrack, which reads the form without subscribing to it. So the effect runs when the stay changes length and stays quiet when the tick itself changes. The message under the tick is a transmit_value like the others.

A ledger every visitor sees

Save writes a real row, and the newest five are the ledger at the top of the panel, shown to everyone who has the page open. The model pings after each save, each channel adds one to a counter, and a derived that read the counter reads the five rows again. Your own line is marked, since each tab renders the ledger for itself. The table is emptied every night.

The same feature in stock Rails

The usual stock Rails build that keeps the rules in Ruby posts the whole form to a validate action on every change and answers with Turbo Streams. Here is a sketch of it. It is not run here, and it leaves out the validations and the three small partials, which would be the same in both versions.

# A sketch of the stock Rails version. It is not part of this app.
class BookingsController < ApplicationController
  before_action :set_booking

  def new
  end

  def edit
  end

  # Every change that settles posts the whole form here. Nothing is saved:
  # the booking is filled in, validated, and its messages sent back.
  def validate
    @booking.assign_attributes(booking_params)
    @booking.validate

    # The server can tell "changed" for a saved booking. It cannot tell
    # which fields the visitor has touched, so the form sends that too,
    # or every empty field is marked wrong on the first keystroke.
    @touched = params[:touched].to_s.split(",")

    respond_to { |format| format.turbo_stream }
  end

  def create
    @booking.assign_attributes(booking_params)

    if @booking.save
      redirect_to edit_booking_path(@booking), notice: "Booked."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @booking.update(booking_params)
      redirect_to edit_booking_path(@booking), notice: "Saved."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def set_booking = @booking = params[:id] ? Booking.find(params[:id]) : Booking.start

  def booking_params = params.expect(booking: %i[name arrives_on leaves_on guests room deposit])
end

The model is as short as before. What grows is everything around it. The stream template keeps a list of every message a keystroke can move, and a Stimulus controller takes on the three things a request cannot know: when typing has settled, whether the form has changed, and which fields have been touched. The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
A message under a field as you type An id on each message, and a Turbo Stream line for each One transmit_value for each field
A message that depends on another field Send every message on every change, since the server cannot tell which moved Nothing; a message is sent when its text changes
Save only when valid and changed Valid from the server, changed from a snapshot kept in JavaScript, joined after each reply dirty? && valid?
An unsaved changes note The same JavaScript snapshot, and a target to show and hide A line in the partial that reads dirty?
No messages on an untouched form Track touched fields in JavaScript and post them along One line in the form's error_for
A field that comes and goes The length of the stay worked out in JavaScript as well, to know when to ask for it An effect that reads nights
What a change sends to the server Every field of the form The one value that changed
Waiting for typing to settle A timer in the Stimulus controller on(:set_field, event: :input)
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
A ledger every visitor sees A stream on the page and a broadcast in the model, which renders the partial itself A ping in the model, and a derived and an effect in the channel
Marking your own line in it Not from a model broadcast, which renders once for everyone; a stream for each visitor, or more JavaScript A local passed to the partial; each tab renders its own
A new rule on the model The validation, plus a message and a stream line if it lands on a new field The validation

Stock Rails has a second way with no round trip: check the fields in a Stimulus controller. It is quick to the eye. However, each rule is then written twice, in Ruby for the booking that is saved and in JavaScript for the message that is shown, and the day one copy changes without the other, the form passes a booking the model turns away. Here the rules stay on the model, and Hibiki carries their messages to the page.