Jonathan Bennett

Simplify Rails Forms with One Reusable Module

After trying out form objects like the SignUp example from last week, you’ll quickly see the value of extracting common logic into a reusable module. Let’s take our SignUp class and transform it into a flexible, module that you can apply across your app.

Here’s where we are starting:

class SignUp
	include ActiveModel::Model

	def user
		@user ||= User.new
	end
	
	def user_attributes=(attributes)
		user.assign_attribute(attributes)
	end

	def account
		@account ||= Account.new
	end
	
	def account_attributes=(attributes)
		account.assign_attribute(attributes)
	end

	def save!
		ActiveRecord::Base.transaction do
			user.save!
			account.user = user
			account.save!
		end
	end
end

Step 1: Extract a FormObject Module

To simplify and standardize how we create form objects, we’ll move the ActiveModel::Model inclusion into a shared FormObject module. Here’s the updated code:

module FormObject
	extend ActiveSupport::Concern
	include ActiveModel::Model
end

class SignUp
	include FormObject
	# include ActiveModel::Model # removed
	
	# …
end

This small change sets the foundation for reusable form object logic across your application.

Step 2: Add Nested Objects Dynamically

The most common use case for a form object is to manage relationships with other objects. We’ll add a has_one method to our module to dynamically create accessors and *_attributes= methods for nested objects. Here’s how it looks:

module FormObject
	class_methods do
		def has_one(name)
			class_name = name.to_s.titleize
			
			class_eval <<-CODE, __FILE__, __LINE__ + 1
				def #{name}
					@#{name} ||= #{class_name}.new
				end
				
				def #{name}_attributes=(attributes)
					#{name}.assign_attributes(attributes)
				end
			CODE
		end
	end
end

class SignUp
	include FormObject
	
	has_one :user
	has_one :account
	
	# …
end

This is the most complex part of the module. What we are doing here is writing the code dynamically and adding it to the class. When calling has_one :user, the class_eval is essentially adding the following to the class:

def user
	@user ||= User.new
end

def user_attributes=(attributes)
	user.assign_attributes(attributes)
end

This approach aligns with accepts_nested_attributes conventions, making the form compatible with Rails’ helpers for nested forms.

Step 3: Batch Save Nested Objects

To ensure all associated objects are saved correctly, we’ll track the added relationships and save them in a transaction to prevent partial saves:

module FormObject
	class_methods do
		def has_one(name)
			class_name = name.to_s.titleize
			@form_object_associations ||= []
			@form_object_associations << name
		
			#…
		end
	end
	
	def save
		ActiveRecord::Base.transaction do
			@form_object_associations.each { |r| send(r).save! }
		end
	end
end

This ensures that either all objects are saved successfully, or none are, maintaining data consistency.

Step 4: Add Validation Support

Since validates_associated isn’t part of ActiveModel::Model, we’ll add a custom shim to validate associated objects. We’ll also ensure all relationships are validated before saving:

module FormObject
	class_methods do
		def has_one(name)
			# …
			
			validates_associated(name)
		end
		
		def validates_associated(name)
			validates_with
				ActiveRecord::Validations::AssociatedValidator,
				{ attributes: name }
		end
	end
	
	def custom_validation_context? = false # needed for validates_associated
	
	def save!
		raise ActiveRecord::RecordInvalid unless valid?
	
		ActiveRecord::Base.transaction do
		  @form_object_associations.each { |r| send(r).save! }
		end
	end
end

With this addition, your form objects can validate nested relationships as expected.

Step 5: There is No Step 5

We’re done! With just 50 lines of code, we’ve created a reusable, flexible FormObject module. Here’s the final implementation:

module FormObject
	extend ActiveSupport::Concern
	include ActiveModel::Model

	class_methods do
		def has_one(name)
			class_name = name.to_s.titleize
			@form_object_associations ||= []
			@form_object_associations << name

			class_eval <<-CODE, __FILE__, __LINE__ + 1
				def #{name}
					@#{name} ||= #{class_name}.new
				end
				
				def #{name}_attributes=(attributes)
					#{name}.assign_attributes(attributes)
				end
			CODE
			
			validates_associated name
		end

		def validates_associated(name)
			validates_with
				ActiveRecord::Validations::AssociatedValidator,
				{ attributes: name }
		end
	end

	def custom_validation_context? = false

	def save!
		raise ActiveRecord::RecordInvalid unless valid?

		ActiveRecord::Base.transaction do
		    @form_object_associations.each { |r| send(r).save! }
		end
	end
end


class SignUp
	include FormObject

	has_one :user
	has_one :account
end

This implementation covers most common use cases for form objects. For a more feature-rich solution, check out YAAF, a robust library for handling form objects in Rails.