Untangle complex flows in your React Native app with XState
Decouple business logic and the views with a well-established CS model.
In this article, I am describing how we modelled declaratively a complex flow of a banking app by leveraging XState, a JavaScript library to define finite state machines.
This is the first article of a 3 part series — check out the other parts:
In the last few months, I have been working on a banking app implemented with React Native.
The latest feature I implemented is a flow to authorise a payment transaction. It works as follows:
- The user receives a push notification, prompting to authorise a transaction;
- The user taps the notification and verifies the payment information. A timer notifies that the authorisation request will expire after a few minutes;
- Until the expiration, the user can either accept or decline the transaction;
- If they accept, they will need to authenticate with a biometric factor (e.g., FaceID). At this point, the app will notify the result of the authorisation attempt;
- If they decline, they will see a confirmation message. We also invite the user to contact the bank if they did not recognise the authorisation request.
It’s easier to grasp by laying out the storyboard for the flow:
The flow gets even more complex if we integrate calls to REST APIs and the device native APIs:
- at step 1 we must check that the device has an enrolled biometric factor before fetching the payment details;
- at step 3B we need to access some data in the Keychain (protected by a biometric factor) and, if that succeeds, call a remote endpoint to authorise the payment.
Decoupling screens and business logic
I wanted to avoid implementing the business logic for the flow in the React components. Doing so would result in mixing business logic concerns (API calls, native calls, timed expiration) and view layer concerns (loading states, navigation between screens, error messages).
At this point, I remembered a wonderful article titled “UI as an afterthought”. Instead of starting the implementation from screens and later think of state and data flow, we should actually do the inverse! We can model the state and interactions first, and map it to the UI later.
This approach brings some advantages: the business logic itself is easier to test, it’s decoupled from the app screens and can be changed without necessarily touching the views.
My attempt to model the authorisation flow decoupled from the app screens was eye-opening! I realised I was actually designing a finite state machine:
There are many solutions for modelling finite state machines in JavaScript. I chose XState, as it is very well documented and is extensively advocated by its author, David Khourshid.
Modelling with XState
Describing a state machine with XState requires setting up two objects, passed as arguments to the Machine
constructor:
- A machine config: we can think of a machine config as a set of states; each state has a unique key and a map of transitions to other states. A state can be final (meaning that no further transition can occur). We also have to specify an initial state;
- A set of machine options: machine options allow to configure some parts of the machine, such as the services (e.g. remote APIs) and delays (timers) of the machine;
XState provides the features we need to implement the model we described in a declarative way. We can invoke services when entering a state; a service is a function that invokes a REST API and returns the Promise for the call:
// in the machine config
checkPrerequisites: {
invoke: {
id: 'checkPrerequistes',
src: 'checkPrerequisites',
onDone: {
target: 'fetchingPaymentDetails',
},
onError: {
target: 'prerequisitesNotMet',
},
},
}// in the machine options
services: {
// we return a Promise here
checkPrerequisites: () => Biometrics.isEnrolled(),
// ...
}
As we transition between states and invoke services, we can store some state in the machine’s context (using XState’s assign):
fetchingPaymentDetails: {
invoke: {
id: 'fetchPaymentDetails',
src: 'fetchPaymentDetails',
onDone: {
target: 'paymentDetailsFetched',
// fetchPaymentDetails resolves to
// { paymentAuthorizationId, expiresOn }
actions: assign((ctx, event) => {
return event.data;
}),
},
onError: {
target: 'paymentDetailsError',
},
},
}
Finally, we can describe states that expire after a while by setting delays on the machine; the XState interpreter will take care of tracking the state timer and transition to the appropriate state when it expires:
// in the machine config
paymentDetailsFetched: {
after: {
AUTHORIZATION_EXPIRES: {
target: 'authorizationExpired',
},
},
// ...
}// in the machine options
delays: {
AUTHORIZATION_EXPIRES: (ctx) => {
return Math.max(ctx.expiresOn - new Date(), 0);
},
// ...
}
Putting the above together, this is a complete implementation of the machine we designed:
Machine options decouple the implementation of services and delays from the machine config — making it possible to plug in different services or stub them for testing. We could also leave out the machine options, and provide them when we actually run the machine.
If you want to play around with the machine we defined, you can fire up the XState Visualizer, an interactive tool to visualise state machines. Try to trigger the possible transitions, or change the implementation of the services to end up in different final states! You can find the machine we implemented at this link.
Phew, that’s a wrap for now!
In this article, we implemented an application flow with XState, a JavaScript library for defining state machines. We were able to implement the whole flow in a declarative way. We could also postpone thinking about the app screens that will expose the functionality.
In the next article, we will complete the implementation by integrating the state machine with React Native and React Navigation. I will show how the separation of concerns allows us to keep the screens code minimal and, most of all focused on presenting information to the user.
Further reading
- UI as an afterthought by Michel Weststrate;
- XState docs;
- The FaceTime Bug and the Dangers of Implicit State Machines by David Khourshid;
Notes
- The pictured finite state machine is not the only way to model the payment authorisation flow. For example, we could have collapsed error states in a single one, and used metadata to distinguish the error; it is a tradeoff in being explicit versus avoiding state explosion.
- The machine options we provide in the gist are just for demonstration purpose; a real implementation would probably call some remote or device API.