• Pricing
Log In
  1. Home
  2. Blog
  3. Engineering
  4. Advantages of Using Declarative Frontend Logic to Combat the SetStep Anti-pattern (Part 2)
Advantages of Using Declarative Frontend Logic to Combat the SetStep Anti-pattern (Part 2)

Advantages of Using Declarative Frontend Logic to Combat the SetStep Anti-pattern (Part 2)

by Daniel Knaust

In the previous article, we explored SetStep Anti-Pattern limitations. Let’s take a look at how we combat it with declarative logic!

☝️ Note: This is part 2 of an article series, so be sure to check out the first article before you continue reading:

Limitations of Scaling a Frontend App with the SetStep Anti-Pattern

Declarative UI Logic to the Rescue!

 

To overcome the mess caused by the SetStep Anti-pattern described in the previous article, here are a few simple concepts to remember:

  • 👉 Minimal-state principle: Only store the minimal possible state, which typically means what the user has selected or received from the backend.

  • 👉 Prefer computed values: All other values should be derived from the state, including even the current step!

  • 👉 Make control flows easy by using early returns: Instead of writing convoluted if-else and switch-case statements, prefer returning values early.

Let’s take a look in practice how these rules play together to create a simple routing logic, rewriting the example above!

And there you go, a much more verbose solution! 😅 Joking aside, it is indeed longer, but also a LOT more extendible.

Actually, it might be even easier to understand what happens here by using plain English in 3 simple points:

  1. Once the user finished a step it gets marked as completed.
  2. The router function (getStep()) is responsible for returning the first step that is not yet completed
  3. The renderer displays the component selected by the router function.

Adding a New Step?

 

Since we only store values that relate to a user interaction (e.g., completing a step), we can easily extend how we compute the current step based on those:

And even better, isBusinessUser is now a separate state entry, so we can derive more computed states from it (for example if we want to show a different thank you page as mentioned above).

Reactive to Any State by Design

 

We also don’t need to worry about using external or global state in the navigation logic, since it’s always computed on the fly!

Having no copying of state with useEffect and no fragmented state-setter logic makes it not just easier to read and maintain, but also less likely to result in inconsistent state.

Bonus: Performance Friendly With Memoization

 

In most cases, switching to a computed state won’t cause any significant overhead in performance, as it usually contains very cheap-to-compute conditions.

However, if performance is a concern, adding memoization is also extremely simple with this pattern:

Clear Responsibility Boundaries for Navigation Logic

 

By following the minimal-state principle, child components only report state changes that are relevant for the router, and do not involve themselves with managing any navigation logic.

This way we don’t need to worry about fragmented logic across files and components, as there is a single source of truth when it comes to how the current step is determined.

Jumping Between Steps?

 

So far we focused mainly on navigation in a pretty linear fashion, but what happens if we want to support skipping steps? Or going back to the previous step?

Addressing these can get a bit tricky, it really depends on what you want to achieve. Covering all options in details is out of scope for this article, so I’ll keep it concise.

👉 Use-case #1: Skip a Step

Imagine an onboarding flow, where the user is guided through a couple of steps before they can start using your app. Each step can be either completed (by completing the task) or skipped.

Skip a step (for example in an onboarding flow)

Skip a step (for example in an onboarding flow)

Solution: The pattern works perfectly, simple track each skipped step in another state value, similar to the isXYCompleted one. And by adjusting the getStep() function it’ll simply work!

👉 Use-case #2: Backwards Navigation

Another common scenario is when a user is allowed to re-visit previous steps in a flow. For example let’s say that you’re buying a plane ticket, and you want to change the dates of your flight that you selected in a previous step.

Enabling backwards navigation

Enabling backwards navigation

Solution: Easy! Just un-mark the previously completed step/steps, and thanks to the routing logic, the user will be on the desired step!

👉 Use-case #3: Jumping Between Steps

This is where it gets a bit tricky, but it can still work with some adjustments in the code. A good example would be when there’s a progress indicator, and users can jump to any desired step in the flow.

Jumping Between Steps

Jumping Between Steps

Solution: This deserves its own article as, depending on the exact requirements, a lot can be adjusted to maximize readability and overall maintainability. Here are some brief options to consider:

  • It’s a non-linear flow Might be that this is not the right pattern to use (see Use-case #4 for example).
  • It’s a linear flow, but users can override the current steps The pattern could be still right, we simply store the user’s override, that can be cleared at any time, falling back to the default routing logic.

Note: Another interesting option that could be useful in some cases is to return an array of available steps (getAvailableSteps()) instead of retuning the single active one (getStep()).

This allows more computation to be done before ultimately retuning the active scene (for example by considering a skipped/overridden step stored in state).

👉 Use-case #4: Menu-like Navigation (Non-linear Flow)

In case you are implementing a menu-like navigation (for example a sidebar), using the declarative pattern could add more complexity than benefit.

Menu-like navigation in non-linear flow

Menu-like navigation in non-linear flow

Solution: This pattern is probably not much help in this case. The easiest solution might be tracking the selected menu, and in most cases there’s no need for any getter function (unless more logic is involved computing the current step).

When To Use It?

 

As with any pattern, this works well in some cases, but can become a burden in others.

👍 We found this to be a really powerful way of organizing business logic for complex user flows (e.g. checkout), as it scales very well with ever-changing product needs.

👎 For very simple and non-linear use-cases, the boilerplate can be an overhead, so for example for a menu or sidebar-style navigation it might not be the best choice.

Key Takeaways

 
  • Look out for code smells such as setStep (or similar). It’s usually an indicator of the state being set implicitly
  • Focus on only storing the minimal state, which in most cases limits to user input and fetched values
  • Values based on the state should be computed dynamically (can be memoized if needed)
  • A good way of structuring the getter function is by leveraging the early-return pattern
  • Ideally, you’ll end up with getStep functions, acting as a single source of truth for navigation logic

Thanks for reading, hope you find this useful and that it helps you write more maintainable code!

Daniel Knaust
Daniel Knaust
Group Engineering Manager @Smallpdf