· documentation · 4 min read

How do Rails Form Helpers Know Where to Go?

Following `polymorphic_path` down the rabbit hole.

Following `polymorphic_path` down the rabbit hole.

Why do Rails 7 Form Helpers accept the parameters they do?

Have you ever wondered how form_with helpers function?

How is it possible that they can infer a) how to handle building a path, and b) whether the path should be to create a new instance or update an existing instance?

TL;DR: if an object defines delegate :persisted?, :to_model, to: an object that inherits behavior from ActiveModel::Model or inherits behavior from ActiveModel::Model itself, the form_with method can effectively figure out the resource name, action, and, if needed, the resource ID.

This can be particularly useful if you are passing an instance of a form object that doesn’t inherit from ActiveRecord::Base into a form helper.

The Deep Dive

The secret lies in 2 clever instance methods that are defined in ActiveModel::Model and leveraged by the polymorphic_path method:

  • persisted?
  • to_model

For the purposes of this exercise, let’s build up the contract required for the polymorphic_path from a PORO (Plain Old Ruby Object). Imagine we have a Person class:

class Person
  def initialize(id)
    @id = id
  end
end

# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `to_model' for #<Person:0x000000011707ec58 @id=1>

If we pass an instance of the Person class to polymorphic_path, the method first checks to see whether the record can be converted to a model by calling…to_model. Let’s give our class the ability to respond to that message:

class Person
  def initialize(id)
    @id = id
  end

  def to_model
    self
  end
end

# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `model_name' for #<Person:0x0000000117327860 @id=1>

As a model, the routes helper expects that our Person class will also be able to respond to the model_name message with an object an object that responds to name:

ModelName = Struct.new(:name)
class Person
  def initialize(id)
    @id = id
  end

  def to_model
    self
  end

  def model_name
    ModelName.new('Person')
  end
end

# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `persisted?' for #<Person:0x00000001173958d8 @id=1>

Next, the polymorphic_path checks to see whether the object is persisted?. When persisted?, the polymorphic_path method attempts to construct a path for the edit action. Otherwise, it attempts to construct a path for the create action.

Let’s see how adding a persisted? method changes the behavior:

ModelName = Struct.new(:name)
class Person
  def initialize(id)
    @id = id
  end

  def to_model
    self
  end

  def model_name
    ModelName.new('Person')
  end

  def persisted?
    false
  end
end

# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `route_key' for #<struct ModelName name="person">

Seems like polymorphic_path is expecting model_name to also respond to :route_key, which would be the pluralized version of the class name:

ModelName = Struct.new(:name, :route_key)
class Person
  def initialize(id)
    @id = id
  end

  def to_model
    self
  end

  def model_name
    ModelName.new('Person', 'people')
  end

  def persisted?
    false
  end
end

# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `people_path'

Success! At this point, polymorphic_path has generated a path that, if defined in our routes file, would map to a controller action! What happens if our model is persisted?? In that case, polymorphic_path expects that model_name will respond to a singular_route_key call:

ModelName = Struct.new(:name, :route_key, :singular_route_key)
class Person
  def initialize(id)
    @id = id
  end

  def to_model
    self
  end

  def model_name
    ModelName.new('Person', 'people', 'person')
  end

  def persisted?
    true
  end
end

# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => NoMethodError: undefined method `person_path'

Pretty close! The only outstanding detail to confirm is that the polymorphic_path is correctly populating the correct ID (i.e. 1) for our instance. After updating the routes.rb with resources :person, we see our person_path is populated with the ruby object’s ID in memory, rather than the ID stored in the instance variable.

To remedy this, we need to override our PORO’s definition of to_param:

ModelName = Struct.new(:name, :route_key, :singular_route_key)
class Person
  def initialize(id)
    @id = id
  end

  def to_model
    self
  end

  def model_name
    ModelName.new('Person', 'people', 'person')
  end

  def persisted?
    true
  end

  def to_param
    @id.to_s
  end
end
# rails console
p = Person.new(1)
app.polymorphic_path(p)
# => "/person/1"

SUCCESS! While building up the API of messages a PORO must respond to is a good exercise to better understand the inner workings of an API we might take for granted, it’s impractical to worry about defining these methods on every object.

Thankfully, we are able to include ActiveModel::Model in our PORO to gain access to these methods, or we can choose to delegate :persisted?, :to_model to another model.

Back to Blog