• Pricing
  1. Home
  2. Blog
  3. Engineering
  4. Limitations of Scaling a Frontend App With the SetStep Anti-Pattern (Part 1)
Advantages of Using Declarative Frontend Logic to Combat the SetStep Anti-pattern (Part 2)

Limitations of Scaling a Frontend App With the SetStep Anti-Pattern (Part 1)

by Daniel Knaust

Managing state in a frontend app on scale can feel like a minefield. In this article we’ll take a look at an especially interesting anti-pattern.

In any frontend application UI logic makes up a big part of the codebase - and as more features and edge-cases are covered, it’s also where most of the complexity and tech-debt gets introduced.

This article is about a particularly nasty anti-pattern that looks harmless when implemented, but can become a real party pooper as the application scales. We call it the “SetStep Anti-Pattern”.

What’s the SetStep Pattern in the First Place?


Let’s take the following simple checkout user journey as an example. We provide users a simple 3-step flow to sign up and become a paying customer:

Simple checkout flow

Simple checkout flow

How does the code look for this? Pretty simple! We create a component for each step, and a container component to take care of the routing between the steps.

And with that we already have a simple implementation of the SetStep pattern. So what’s wrong with this code? Nothing at this point. But as we expand this solution, things begin to fall apart quickly.

But What If…? Why Is It an Anti-pattern?


One day, after a brainstorming session, the team decided to add another step. In case the user chooses to sign up as a business, they’re prompted to enter their company’s billing information.

Adding a new step to the checkout flow

Adding a new step to the checkout flow

To support this optional step, we needed to extend our event handler to navigate to the right step based on what the user has selected:

And this is where we have the first clue of future scaling issues: instead of storing what the user has selected, we are storing computed state.

This means, in case we need to access the selected value, or customize the experience based on it (e.g., show a different thank you page for business users) we’ll need to save the user’s selection, in addition to the computed step value.

And if both step and isBusinessUser are being stored separately, we have the risk of getting the two values out of sync. (And it’s definitely not fun debugging why some users see something they shouldn’t. 😬)

Reacting to Dynamic State Changes


Handling dynamic changes in state and reflecting them in the navigation between steps is also tricky in this pattern.

For example, what happens if the user gets logged in while having the checkout open? Since the current step is tracked “statically” in local state, we’ll need to explicitly update it when relevant pieces of the global state changes.

For example:

If we follow the setStep pattern, we find ourselves having to put parts of the routing logic in a useEffect to react to global state updates. This start to get convoluted as we are already duplicating some code for the business users’ use case.

And you can imagine how quickly this escalates if we depend on more than one value from external state when selecting the active step.

Navigating Back to the Previous Step?


Another common limitation of this approach is if we need to consider backwards navigation. If the user wants to go back from the “Payment” step, we’ll need to know if the user is a business or not, and we need to write the same logic again to show the right step.

This means we have duplicated routing logic, just to implement the forward and backwards navigation. And by adding more steps and conditions, the complexity will increase exponentially.

☹️ With this pattern, the routing logic becomes fragmented across different event handlers with duplicated conditions.

Common Mistake: Just Let the Child Component Take Care of the Navigation!


This is an even more dangerous shortcut we’ve seen (and suffered from) countless times: passing the setStep function down to the child component.

This mistake is especially easy to be made with a global state management solution (like Redux or Recoil), since the dispatch function can be accessed without explicitly passing it down.

Why is it such a big deal? The navigation logic has leaked down into the individual steps, which also means the steps have to be aware of the routing logic too.

This makes maintenance more difficult. For example, in case a new step is added, navigation has to be implemented across all previous steps, which usually results in unexpected behavior (= bugs).

Recap of Shortcomings


On the surface, this pattern looks very straightforward, but as soon as we deviate from a simple linear flow, it quickly becomes unmaintainable.

Using setState is very often prone to:

  • Fragmented logic and control flow (usually across event handlers and other functions, worst case even across files and components)
  • Duplicated business logic
  • Out-of-sync or not reactive state (especially when dependent on external state)
  • Rigid to change, which includes:
    • adding or removing steps
    • changing order of steps

Declarative UI logic to the rescue!


How to combat the limitations of the SetState anti-pattern? There’s a whole other article about how to leverage declarative UI logic to avoid scaling issues with pattern!

You can check it out here: Advantages of Using Declarative Frontend Logic to Combat the SetStep Anti-pattern.

Thanks for reading!

Daniel Knaust
Daniel Knaust
Group Engineering Manager @Smallpdf