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 calling
saveon your models to make sure they are calling
save(context: :my_context_here). It'd be nice if there was simply a
inject_validation_context(:my_context_here)to "poison" your
Userinstance with that context for its instance-lifetime. And, there is; just use
user.validation_context = :my_context!
- You can only inject one context at a time. If you want to validate
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 calling
valid?first with the "step 1" context before calling
savewith the "step 2" context. Messy.
ActiveModel::Model form objects
The next solution to this problem is simply using
(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
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
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
fails. For example, you might have
password constraints in your
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
- 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"
- 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
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
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)
This isn't perfect for downstream libraries you depend on, too; Devise
will break when calling
respond_with because you're giving it a
resource that is a
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.
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.
By Daniel Starling
Software consultant in Portland, OR
Software consultant in Portland, OR