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 callingsaveon 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" yourUserinstance 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
Useron "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 callingsavewith 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
userobject - Through some means, I can hint that
useris subject to an additional form object's validations - A subsequent call to
user.savewill trigger the additional validations as well as the "base"Uservalidations - Finally, the
userinstance 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.