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 save on 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 User instance 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 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 calling valid? first with the "step 1" context before calling save 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.


By Daniel Starling

Software consultant in Portland, OR

Contact me

Daniel Starling

Software consultant in Portland, OR

Need help? Contact me!