Cleanly validating multi-step forms in Rails
February 13, 2018
I found myself needing multi-step form functionality recently. I am a big fan of not relying on extra gems when possible, and if I need to hook Rails core, it needs to be light, easy-to-understand code.
For me, a "multi-step form" really means "I need forms that validate my Rails objects in different ways that don't lend well to model-based all-or-nothing validations".
I'll outline some approaches, their weaknesses, and how to work around those.
Rails validation contexts
The first solution is to use in-model validation contexts.
This works in a pinch, but I don't like these – embedding conditional symbols
in your model validations quickly results in difficult to read models that
embed too much information about how your front-end forms work. If you
have a non-trivial 3-4 step User
signup process, that's 3-4 contexts
cluttering up your User
model code.
This approach also invites the antipattern of using the context as a substitute
for conditional validation. For example, injecting a context to hint that a user is
in the US and needs an extra geographic_state
field validated
is a no-no! Use a validation's if
for this.
One way to clean up your User
code is to isolate the contexts into
their own concerns, named after the context :symbol
. This seems to work pretty
well if you don't need a lot of virtual (i.e. not-in-the-database) fields
as part of a multi-step form.
There are some gotchas here, still:
- You can only inject the context when calling
save
. This means that you must take apart gems that are callingsave
on your models to make sure they are callingsave(context: :my_context_here)
. It'd be nice if there was simply ainject_validation_context(:my_context_here)
to "poison" yourUser
instance with that context for its instance-lifetime. And, there is; just useuser.validation_context = :my_context
! - You can only inject one context at a time. If you want to validate
a
User
on "step 2" and want to make sure they still pass "step 1" validation (i.e. didn't skip it somehow), that's hard to do without essentially saving twice, or callingvalid?
first with the "step 1" context before callingsave
with the "step 2" context. Messy.
ActiveModel::Model
form objects
The next solution to this problem is simply using ActiveModel::Model
(see this great ThoughtBot article).
This works very nicely because it compartmentalizes the idea of a front-end
form as a back-end validation concept (no polluting that User
model),
but the downside is that there is no notion of inheritance. If you
define that a User#first_name
is required in your User
model, then
you must repeat yourself in your UserForm#first_name
(assuming UserForm
is your ActiveModel form object).
This approach also encourages the UserForm
to create the User
model instance.
It does not prescribe how to handle a situation where a base User
validation
fails. For example, you might have password
constraints in your User
model that won't be evident to the UserForm
providing initial validation.
There are many ways around this, but let's hone in on one that has the following properties:
- I can instantiate and assign attributes to a single instance representing
my intended
user
object - Through some means, I can hint that
user
is subject to an additional form object's validations - A subsequent call to
user.save
will trigger the additional validations as well as the "base"User
validations - Finally, the
user
instance will have all validation errors (for "base" validations as well as the additional form validations)
In more informal terms, we want to instantiate a User
model and a
UserForm
form object, and somehow merge them into the same object instance
before we save
!
As of Rails 5, validations are still defined at the class level. This makes
it difficult to dynamically inject validations without lots of extra
boilerplate. We could probably do this by constructing an anonymous class
that inherits from User
and includes UserForm
as a mix-in, then instantiate
it, but generating dynamic classes is a bad idea from a garbage collection
perspective, and interferes with Ruby class caching.
Provided that you aren't trying to use multiple form objects at once, there is a reasonably clean approach that satisfies our wants here without hacking too much:
# Let your form object descend from the model it is for
class UserRegistrationForm < User
# This field is only necessary upon signup
validates :company_title, presence: true
end
This is arguably an abuse of inheritance (a form is not really a kind of
User
), but does allow us to cleanly respect base object validations while
overlaying additional form validations.
Now you can use:
user = UserRegistrationForm.new
user.attributes = params[:user].permit(:name, :company_title, :email)
user.save
This isn't perfect for downstream libraries you depend on, too; Devise
will break when calling RegistrationsController#create
near
respond_with
because you're giving it a resource
that is a UserRegistrationForm
,
so it can't determine what to reply with if a user fails validation. The easy
fix is to override RegistrationsController#create
, but this does get dirtier.
Conclusion
Rails "form objects" are usually a good fit, but it gets a bit dirty if you have a signup form that
happens to validate core User
validations in addition to some extra fields that need to be present.
This can be worked around via inheritance as shown.