Blanks is a form object library built on ActiveModel. You define attributes, validations, and associations the same way you do in ActiveRecord. It works with form_with, fields_for, and all the Rails form helpers without configuration. The tagline is “fill it in”—most forms don’t map to a single database table, and some don’t map to the database at all.
Why it exists
Form logic in Rails ends up in weird places. Controllers do too much. Models grow validation rules that only apply to certain contexts. You create a User but the form also needs a company name, terms acceptance, and a referral code—none of which belong on the User model.
The usual solution is a plain Ruby object that includes ActiveModel::Model. That works until you need nested attributes. Then you’re writing accepts_nested_attributes_for by hand, tracking IDs yourself, and wondering why Rails forms won’t cooperate.
Blanks handles all of that. Define your form, define your associations, and Rails just works.
How it works
Attributes are typed with optional defaults, same syntax as ActiveRecord. Associations use has_one and has_many that expect corresponding form classes. Nested attributes come for free—Blanks tracks IDs so it knows which records to update versus create.
class PostForm < Blanks::Base
attribute :title, :string
attribute :content, :string
validates :title, presence: true
has_many :images
end
class ImageForm < Blanks::Base
attribute :url, :string
attribute :caption, :string
end
That’s it. PostForm now accepts nested attributes for images, tracks which ones already exist in the database, and validates the whole tree.
Loading from models
Pull data from existing records without redefining everything:
class UserForm < Blanks::Base
inherit_from User, except: [:created_at, :updated_at, :password_digest]
end
form = UserForm.from_model(user)
form.assign_attributes(params[:user])
model_attributes extracts just the top-level attributes for updating. attributes extracts everything including nested records—useful for accepts_nested_attributes_for on the actual model.
Dirty tracking
Know what changed since initialization:
form.title = "New title"
form.title_changed? # true
form.title_was # original value
form.changes # { "title" => ["Old title", "New title"] }
Helpful for conditional logic in callbacks or skipping unchanged records.
Normalization
Strip whitespace, downcase emails, format phone numbers—whatever you need:
normalizes :email, with: ->(email) { email.strip.downcase }
The transformation runs on assignment. It’s idempotent, so assigning the same value twice won’t cause changed? to lie.