Developing a wizard or multi-steps forms in Rails

Subjects covered in this article : binding Rails forms to custom objects, ActiveModel::Model, ActiveSupport delegate method, ActiveSupport constantize method, custom exceptions, the Rails session object, good practices…

All Rails developers who already had to create “multi-steps” forms or a wizard know that it can be a painful feature to develop. One of the main reasons is that by default, Rails forms are linked to a single database instance. What if you want to create multiple steps and forms before the record is actually saved in the database, and each step containing different validations? Dealing with data which is not persisted yet between multiple requests is not trivial…

Let’s say we want to split the form for one single record into several parts. For this example, we’ll take a User model who must have an email, first name, last name, a full address, a phone number, etc. Instead of one big form, we would like to split the form into — let’s say — 4 parts. We only want to create the user record in the database in the final step, not before, and we would like to maintain the ‘state’ between all the steps. The user may fill the 3 first steps, go back to the first or second one, change something, advance again, and so on.

Like everything in computing, there is no single — or perfect — way of doing a task, but hundreds. What we are going to see here is one way of doing a wizard for a single record in the database. At the end of this article we’ll review another potential method of achieving the same result.

Here are the constraints for this example :

  • we must use static Rails views and validations for each step, no front-end/JavaScript logic,
  • we won’t use an external gem or library, just standard server-side Rails code,
  • the state of each step must be persisted until the final step is validated,
  • each step may have validations which must pass to go to the next step,
  • the record in the database is only created at the validation of the final step.

One of the first questions we may have :

  • Where should we store the logic of the steps validations ? In the controller, the model responsible of storing the record (the ActiveRecord model), another class or classes?

Let’s remove the controller from the list : the controller should not be the place to store data validations. Also it’s not a good idea to store “view-logic” inside the main ActiveRecord model. We’ll keep the main User model free from the logic of the wizard, its representation should be the state of the record when saved in the database. So the User model may look rather classic like this…

We’ll create Ruby classes not linked with persistence to handle the different forms, one for each step. By including the module ActiveModel::Model we still get the possibility of using an instance of these classes in Rails forms and also get all the default Rails validations, which is quite nice.

Here is how the classes look like…

Some notes…

  • We could create a single class for all steps forms and use conditional validations with the if option on validations, but I simply prefer to create one class for each step. By doing this, we don’t clutter our class with conditionals depending on the current step. We may put all the “steps” classes in the same file like above or we may put them in separate files, it does not really matter.
  • Where should we store this file representing objects linked to view forms? We may put it inside app/models but it’s maybe not a good idea to mix models representing the data stored in the database and view-logic classes. Remember that the Rails autoloading feature will load every Ruby class inside the app folder, feel free to create another folder inside it to only store your view-logic classes (don’t forget to quit and run again your local server when you do this). I’ve put it inside a new folder called app/form_models.
  • We inherit each step from the previous one, so that every step gets the validations of the previous ones for free, again without cluttering the class with conditionals or validations options.
  • We store the User instance (the ActiveRecord instance) inside an attribute called user. By doing this, we will be able to easily access the user instance and save it on the final step. To easily link our view forms to all the attributes from the user, we use the delegate method (from Rails ActiveSupport) to map all the attributes of the user instance inside all the view classes. By default, delegate will only delegate the reader methods. Because we also need all the setters for all attributes, we have to explicitly write it (this explains the map call you see in the Base class).

Now, let’s take a look at the controller code…

Each step is mapped on a GET action : step1, step2, step3 and step4. I’ve not added these actions in the controller because there are empty, you may declare them if you prefer being explicit. Before displaying the view for each of these steps, we load the current step class and store it in @user_wizard, for example on step1 we instantiate Wizard::User::Step1.

Every time the user clicks the Continue button to go to the next step, the method validate_step is called. Then this method redirects to the next step, or, in case of validations errors, renders the current step again to display the validations errors to the user.

As you can see, before displaying each step, the load_user_wizard method is called. This method uses the current action name to initialize an instance of the current step class and store it in @user_wizard.

In the wizard_user_for_step method we use the constantize method from ActiveSupport to dynamically instantiate the corresponding view class from a string. This method is a better practice to go from a string to a class name than to use the more generic Ruby eval method. Of course, to avoid any problem we check that the step passed as a parameter string is an existing step. Otherwise we raise the InvalidStep exception. Defining and raising your own exceptions in your Rails application may also be a good practice in most cases. It’s easy and handy to “rescue” your custom exceptions globally by using the rescue_from method. When instantiating the view class, we pass to it session[:user_attributes], which is nil by default. This session variable will be populated with the new user attributes every time the user validates a step.

Each time the validate_step method is called when the user validates a step, we store the attributes of the user instance inside the session Hash. Why not directly store the whole user instance? Because basically it’s a good habit to only store “simple” Ruby objects inside the session. By simple, I mean basic types like strings, integers, small arrays and hashes. So instead of storing the user instance in the session we store all its attributes as a Hash (using the ActiveRecord::Base#attributes method).

Finally, when the user validates the last step, we run the create method. This method is rather straight-forward. The user instance of the current @user_wizard variable is already populated with the latest attributes. We can directly call the final #save on it to record the user in the database like any classical create action. Then we redirect the user with a flash message…

As said previously, we’ve reviewed one way of doing a wizard in Rails : mostly on the server side with classic static views. There may be other ways that you should evaluate, like managing the steps totally on the front-end using JavaScript. By using JavaScript you will have to shift some of the logic from the server to the views, and surely you will have to consider using some JavaScript framework like React, Angular or Vue. If you begin to shift a lot of logic to your front-end without using a framework, the code may become difficult to maintain in no time…

You may check the full code of this wizard implementation, with RSpecs and Capybara integration tests here :

Use the Deploy to Heroku button to automatically deploy the sample example on your Heroku account (it’s free of course).

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.