Navigating through complex flows such as onboardings or payment processes is a common challenge in app development.
Last year, upon joining a new project, I was tasked with adding a page to an already implemented flow. The code of the flow was the least clear I had ever seen - it made it really hard for me to get the big picture of all the possible pathways.
Being the sole developer on the project, I lacked guidance. I spent days analysing code to understand the flow as a whole, scrutinising every line of code to decipher the navigation path between pages. And even then, I wasn’t confident about adding a page within this flow.
So, a few months later, when tasked with developing a new flow, I was determined to make it look as clear and accessible as possible to new developers. This is the story of our reflection towards the ideal solution to navigate through our flows. We’ll explore different strategies, from using a state machine, to building our own custom navigator.
Before we get started, it's crucial to clarify what we mean by a "flow" and how it stands apart from conventional page navigation.
A flow orchestrates a series of pages, leading users through a predetermined sequence. Unlike traditional navigation, where users choose their next destination, a flow systematically guides them, step by step.
This structured approach contrasts with standard navigation, which allows users more freedom in their journey:
A flow can be simple, or grow to be very complex. Mine was one of the latter, involving lots of steps. Some parts of the flow were only presented to a subset of users, while other parts were shown to every user, but only one.
Consider a complex flow, with conditional steps, such as this one:
You don’t need to read the flow thoroughly to understand its nuances: It represents a payment tunnel with some pages, such as a promotion code page, shown only during the first purchase, while some others are exclusive to specific types of purchases.
If you implement it normally, relying on standard page by page navigation, you end up with the flow’s logic split and nested within each page.
This approach presents multiple issues:
First, it is hard to get a good overview of the flow. To get a good picture of all the possible pathways of the flow, you have to dig into each page, see where it leads and put it all together. This is why I had such a hard time getting into the complex flow on the project I arrived on.
Secondly, this violates the single responsibility principle. The pages now have the huge responsibility of directing to the right page, of knowing the flow, that goes on top of the other UI and logical responsibilities they already have. Ideally, this logic would be centralized in one place - in a state machine for example - indicating the subsequent page, removing this responsibility from each page. Something that would look like this:
These challenges contribute to a solution that is difficult to modify, maintain, and is prone to errors. Faced with integrating a new step into such a convoluted flow, the fear of disrupting the existing structure was palpable. I wasn’t sure to know all possible pathways, and was scared to forget to add a path at a crucial place.
Knowing why standard page-by-page navigation would not provide us with a good overview of a flow, what better alternatives exist? Tasked with developing a new flow for a different project, we were committed to finding a more effective solution.
Relying on standard page-by-page navigation was not an option. It would not provide us with a comprehensive overview of the flow. Our goal was to define the logic of our flow in one place that would accurately provide the full scope of our flow at a glance.
Reflecting on the optimal solution, we turned our attention to the concept of state machine. Fundamentally, a flow operates similarly to a state machine, characterised by a series of sequential changes in state throughout a given process. So, what better library to assist us in managing our flow than xState? It encapsulates a process defined by state transitions, making it an invaluable resource for both implementing and documenting our flow's architecture.
Our state machine specifies which page is currently being focused on, and from this data, we derive our navigation state.
This method worked great, but had a few drawbacks. The main issue was the duplication of the state indicating the current step. We had two states we had to make sure we kept synchronised manually:
Synchronising these states involved aligning the navigation state with the state machine’s status. Meaning, inside the state, every time the state changes, you navigate, something like this:
Forget to synchronise these states, and you have a bug.
We tried a few other strategies to avoid this duplication.
One of them was a declarative approach, involving substituting the conventional navigator with direct page rendering based on the state machine's current value:
With this method, when the value of the state machine changes, the appropriate page is dynamically rendered. This eliminates the need for traditional navigation, significantly reducing the risk of forgetting to synchronise the two states.
The main drawback of this method was losing the navigation animations.
You can check out the different methods we tried here if you’re interested [https://github.com/charlotteisambert/navigation-with-xstate] - but none of them fully met our needs.
Until one of our colleague asked us: “Have you considered creating a custom navigator?”
Turns out you can easily craft your own custom navigators (https://reactnavigation.org/docs/custom-navigators/) tailored for your own needs.
We have navigators for tabs and drawers; why not one for flows?
This would fix our problem of having duplicated states: our only state, our only source of truth, would be our navigation state.
So, what would be the best possible API for our custom Flow Navigator
?
Let’s look at an example
Picture a payment flow which has three steps:
Easy enough
We aimed for a straightforward, declarative API. This is what we came up with:
First, when creating your Flow Navigation stack, you do more than just list your screens; you also establish their sequence. Screens have to be defined in their order the flow.
This provides new developers with a comprehensive overview of the entire flow in one single location, without the need to sift through all the pages to grasp the whole sequence.
Next, instead of navigating from one page to another one, you call the goToNextStep
and the goToPreviousStep
methods
This way, CartReview remains unaware of its position within the flow. It is not responsible of knowing the flow anymore, nor of saying which page should be the next one.
We now introduce a “Document Signing” page, required for all customers to sign once. We have to display it only if it is a first purchase.
We can define this conditional step in the navigation stack declaration.
<script src="https://gist.github.com/charlotteisambert/e604c177497ed377f75fe3fa733e1fd3.js"></script>
The first time we enter the flow, with hasToSign
being truthy, the document signing page will be displayed. We can disable it when leaving the page:
The first time we enter the flow, with hasToSign being truthy, the document signing page will be displayed. We can disable it when leaving the page:
Before going to the next step, we set the hasToSign
flag to false. As a consequence, the PaymentFlow
component is re-rendered, removing the DocumentSigning
page from the navigation stack.
This means that, if we go back from the following page, PaymentDataForm
, we will not go back to DocumentSigning
, but to CartReview
, just like the flow requires.
As our flow evolves, some steps may require several pages:
But fear not, we can easily group our pages in one step using Groups
This is the API we opted for.
We imagined a simple and easy to use API for our flow navigator that anyone could benefit from, no matter how easy or difficult the flow is.
For particularly intricate flows, we considered the possibility of an API that more closely resembles a state machine. With our current API, a quick glance at the navigation stack declaration reveals the sequence of pages and which ones can be displayed conditionally. But it doesn't explicitly show all possible navigation paths.
Take an example:
At first glance, the potential values for flag1
, flag2
, and the feasibility of navigating from PageB to PageC at any given time remain unclear: we don’t know whether the path from PageB to PageC is possible.
This issue could be addressed with an API that incorporates a state machine configuration like the following:
With this detailed configuration, it becomes clear that a direct transition from B to C is not possible.
This API concept is something we could envision for our flow navigator to accommodate advanced use cases. We're keen to understand your needs and use cases: would such an API be beneficial for you?
On our journey to building the optimal architecture for managing flows, we've engaged in numerous insightful reflections. From the realisation that navigating from page to page didn’t fully meet our needs, we’ve embraced the idea of a state machine and combined it with react-navigation, which has significantly clarified our flows!
As we’ve encountered the need for a flow navigator in several projects at BAM, our goal is to continue integrating it into future projects, enhancing its capabilities.
We’ve published our package on npm here, and you can check out the source code here.
The flow navigator currently works only with react-navigation, our next step is to make it compatible with expo router as well.