Complexities of a progress stepper in a checkout flow

When obtaining data from a customer during a checkout flow, stepper components have been a UX staple for a while already. Asking the user one thing at a time seems to generally help to reduce complexity and provides guidance through the flow. However, I’m not much of a UX guy, so that’s a topic for some other folks to talk about. What I’d like to talk about here are problems encountered while architecting and implementing them in the frontend. As I had to learn the hard way, there are quite a few things to trip over.

What a progress stepper looks like to the end-user (Material Design)

What a progress stepper looks like to the end-user (Material Design)

Are they really complex?

Well… yes and no. It really depends on your exact requirements. If, for example, the exact steps are already known in advance, are either completely static or require only very little dynamic support, then yes, they’re rather simple to implement.

However, there are plenty of scenarios and requirements that can make them fairly complex rather quickly:

These problems have to be addressed somehow - and ideally, especially on a bigger project, they’re addressed in a way that most developers working on the project won’t have to individually deal with these hassles. The principle of least astonishment is rather important here: If we implement an abstraction to make it easy to create such stepper flows, it should give some guarantees on how it behaves, and those guarantees should ideally match what one would intuitively expect.

Some Learnings

There are plenty of footguns while addressing the problems above, and I’ve made some learnings along the way.

Be ready for new requirements (and unexpected edge-cases)

The biggest and most important one is probably: Be open and ready for new requirements for your stepper. Unless it is set in stone that a flow won’t ever change and it is agreed with all stakeholders that we specifically implement just this exact flow and nothing else, prepare that sooner or later, there will be new requirements.

This sounds fairly obvious, but I’ve seen too many projects already (including some that I was part of) ending up in a tight spot because too many assumptions of what a stepper should and should not support were made early on, often to enable delivering some minimal viable product on a tight deadline that was a greatly simplified version of what should be possible later on. Another issue is that on the surface, it often really does look simple, so developers are tempted to just use some imperative coding without any abstraction. This often falls flat once we have to make things more dynamic due to tight, rigid coupling between the steps and the stepper.

Abstract towards declarative, configuration-based approaches

It can be very useful to abstract towards a declarative, configuration-based setup - because a configuration conveys the intent clearly. Even if the underlying implementation that executes based on the configuration changes, the original intent of the configuration is still clear. If we implement it imperatively, it can be hard to refactor it to be able to deal with new requirements. To give a bit of an idea, a configuration might look like this:

/**
 * No matter the implementation, a config as below makes it clear
 * that "some-flow" consists or step-A/B/C (whatever that might mean),
 * and that we can expect them to appear in the listed order.
 */
const flowConfiguration: Record<flowIdentifier, stepIdentifier[]> = {
    'some-flow': ['step-A', 'step-B', 'step-C'],
    'some-other-flow': [
        'step-A', // We might want to reuse steps across different flows.
        'step-X',
        'step-Y',
        'step-Z'
    ]
};
/**
 * Again: No matter how our implementation looks like and how much of it we need to refactor:
 * It is clear that when "entering" step-A, we need to invoke "onEnterStepA",
 * and when leaving it, we need to invoke "onLeaveStepA", and that whether
 * step-A is shown at all, is determined by "isStepAShown".
 */
const stepConfiguration: Record<stepIdentifier, StepConfiguration> = {
    'step-A': {
        someLabel: 'Step A',
        isShown: isStepAShown,
        onEnter: onEnterStepA, // this could be an async function, a promise, an rxjs-subject, etc.
        onLeave: onLeaveStepA
    }
};

The primary motivation of such a configuration is really to decouple implementation from “declaration of intent”. This allows for much easier refactoring and implementation of new requirements.

You might wonder, why do we have some “onEnter/onLeave”-hooks directly on the configuration? Why not let the steps themselves handle whatever they need? When transitioning between the steps, we need to be able to give some temporal guarantees. For example, when we go from ‘step-A’ to ‘step-B’, ‘onLeave’ of ‘step-A’ must be finished before ‘onEnter’ of ‘step-B’ is started, as there might be a data-dependency. ‘step-B’ however should in no way be aware of ‘step-A’, hence, if we want to keep the steps independent, we need to find an abstraction which gives us these guarantees.

The configuration above is relatively simple - and fairly close to an early iteration I’ve once worked on. A few considerations:

Do not fight the browser to enable browser history

If one of the requirements is to support browser history (meaning: history.back()/history.forward() behave as expected), one of my big learnings is: Do not fight browser history. Embrace it. Even with all the odd cases you possibly need to think about, it’s still simpler than trying to hack the browser to your will. Once a setup is in place, allowing for browser history to work as intuitively expected is quite the magic to behold, it’s worth the effort.

A few concerns worth thinking about:

Allowing to jump forward more than one step is a trap

Imagine you’re the user on some step in a flow, but then realize that three steps earlier, you’d like to change something. We can simply click on the label of the corresponding step to jump back. Now, someone from UX might suggest or even require that if someone “edits” an earlier step, we can jump back to where we have originally been, skipping multiple steps in-between.

Unless flows are strictly static, allowing to “jump forward” is nothing short of opening pandora’s box. Some of the steps in-between could have changed due to dependencies to the earlier step, and either before or after jumping back we somehow need to account for this. It’s one of the very few requirements which I think is very ill-advised to implement. The dependencies between the steps aren’t always obvious, and on the surface, it can seem like an easy UX improvement, but allowing for arbitrary back-and-forth without accounting for all the dependencies in-between will open up edge-cases which will be very hard to control for.

Real users will go back and forth - tests should too

Supporting browser history and allowing to go back in the flow will introduce a lot of possible paths through the application. Real users will certainly make use of the back-button at any given point in time - the tests should also account for that! We tend to primarily test the happy path to go through the flow nicely one step after another - but our actual users will very much go back and forth. Having at least a few tests which specifically cover such scenarios is definitely valuable.

Conclusion

If the requirements demand for a highly dynamic progress stepper, it can be quite a tricky beast to tame, and it’s hard to build an architecture which takes care of all the edge-cases appropriately. Even after a few years of experience with such steppers, new surprises still occasionally surface - at least to me, it’s a rather interesting problem-space. Often, they look simple on the surface, and it’s therefore sometimes a bit of a struggle to make sure all the involved teams are on the same page. Ideally, for the devs involved, the setup “just works” (principle of least astonishment), but it can take a while to get there.