How to build a smart multi-step form in React

Emmanuel Gautier
Apr 15 · 5 min read
Illustration by Garance Bigo

A housing architect friend of mine once told me that every building has exactly two sides: the visible, and the invisible. It’s something you’ve probably thought about if you’ve ever had the eerie experience of visiting multiple cookie-cutter condos in the same building: the floor plan, structure, and foundations are identical, but the tiling, paint, and decoration have diverged over the years. So I asked my friend, ‘What about the opposite? If you built visually identical houses, with completely different foundations for instance? Which one would be the better house?’ He laughed and said, ‘The one that collapses last!’

I recently thought about my friend’s joke in terms of software and, well, he was spot on.

The mission

Back in september 2020, my team was tasked with adding a feature to our medical software: a multi-step form (also known as a wizard) for patient identity verification. Basically, at the beginning of a consultation, the doctor will need to scan their patient’s social security card, link it to the right patient file, fill in some crucial info manually if missing (names, birth date, sex), and — why not — merge duplicates of the patient file if our software finds any.

As you may have guessed, this was not exactly a top feature request — nobody likes an extra administrative step before getting on with work — but rather, a micro-chore to keep the doctor’s medical data reliable and up-to-date. Why does it matter? Imagine prescribing medication to the wrong person!

With that in mind, our main goal was to build a painless user experience: make the wizard simple, forgiving, and capable of automatically skipping steps that aren’t relevant for a given patient.

‘Cool’, I told my product manager, ‘ambitious but cool. Let’s build something nice!’

‘Oh’, he said, ‘by the way we need a first version of this for the beta, which we’re launching in three weeks.’


(dialogue exaggerated for dramatic effect)

Well, this was going to be more duct-tape than beaver dam, I thought to myself. Of course, the two of us were happy to trim off some non-essential parts of the project, but at the end of the day, there was still a large chunk of features to ship in a tight three weeks. We weren’t going to have time for satisfying abstractions.

Version 1: A bare-bones sequence of modals

Once we had built the components for each step’s modal, we needed to plug everything together. To help us in our quest, we have an in-house modal presentation API that can be called from anywhere in the app:

We had a pretty big constraint: after finishing the wizard, we needed to initiate a medical consultation. The callback that triggered this was generated in a very context-specific way, so we were forced to generate it from the beginning of the wizard and pass it around the modals throughout.

Well, it worked. We shipped on time. Our ‘house’ looked (and behaved) the way we wanted it to, pretty much. This was satisfying enough for a beta version, but looking just one step ahead, we could already see why the woodwork wouldn’t hold up:

  • The code was very hard to read and to maintain. Someone looking at the code for any of the modals could barely tell that it was part of a wizard. Even if you were aware of the wizard’s existence, you would need to go on a quest across several files just to figure out its step progression, never mind understanding the subtle logic of skipping this step or that one.
  • Our wizard was not flexible. Want to insert a new step between two others? You’ve gotta unplug and replug everything, basically. Want to have two versions of the flow with slightly different behaviors? Get ready to bloat your payloads even more with ternaries. Want to be able to switch the patient file you started with midway if you realize it wasn’t the right one? No way, the callbacks were computed from the start of the wizard. As a matter of fact, the wizard was supposed to do all of those things eventually, so, yikes.
  • There were a few unwanted behaviors due to the fact that our wizard was stateless, the most annoying one being, no going back to the previous step (again, feasible but painstaking to code, so it didn’t make it in version 1).

Going forward, we simply couldn’t afford this architecture, and given that we had met our deadline, we now had time for a rewrite of our wizard.

Version 2: Like a state machine 🎶

We set two main objectives for our second iteration on the wizard:

  • Centralize the code, so that understanding and maintaining its logic is as easy as possible.
  • Add stateful logic, so that we can observe, control, and modify its behavior.

At the end of the day, the goal was that we able to control the wizard from anywhere, like so:

With the wizard’s entire logic black-boxed away in a single file, the wizard just handles itself and saves you the trouble of wondering what the next step in the process should be. So how did we achieve this?

We start our wizard component with an array of steps, each represented by an object containing two callbacks, effect and shouldSkip. Here’s an example:

And we introduce a state variable currentStepIndex. See where this is going? When asked to trigger ‘the next step’, we move forward in the steps list, until we find one that shouldn’t be skipped, and we trigger that one.

The initiateIdentityWizard callback is even more straightforward, it initializes the state with the data it’s received and kickstarts the wizard. Typically, the initial patient is stored in the state and can be easily switched later on:

And that’s pretty much it.

Note that our wizard component doesn’t render any visual components itself. It uses our openModal API to present a different modal on each step. The only JSX we render is a React context provider:

Now that our component exposes its member functions via a React context, anywhere in the tree below the provider, we can consume this context and call these functions:

Just like we wanted! And now, editing the steps in the wizard is as easy as adding, removing, swapping, or modifying items in the STEPS array.


As any engineer knows, there are a million ways to code a given user interface, with a given behavior. The one you want, ideally, is the one that doesn’t collapse on the slightest change, especially for user-critical wizards with complex rules.

This project in particular was a great way for us to see the difference between a ‘naive’, bare-bones architecture, and a well thought-out one. Adding the right layer of abstraction made our code noticeably more robust, and more maintainable.

If you want more technical news, follow our journey through our docto-tech-life newsletter.

And if you want to join us in scaling a high traffic website and transforming the healthcare system, we are hiring talented developers to grow our tech and product team in France and Germany, feel free to have a look at the open positions.


Improving Healthcare for Good


Founded in 2013, Doctolib is the fastest growing e-health service in Europe. We provide healthcare professionals with services to improve the efficiency of their organization, transform their patients’ experience, and strengthen cooperation with other practitioners. We help pati

Emmanuel Gautier

Written by


Founded in 2013, Doctolib is the fastest growing e-health service in Europe. We provide healthcare professionals with services to improve the efficiency of their organization, transform their patients’ experience, and strengthen cooperation with other practitioners. We help pati