Integrate XState with React Native and React Navigation

Let app state drive the navigation between screens

Simone D'Avico
WellD Tech
6 min readFeb 2, 2021

--

In this article, I show how to integrate an XState state machine with React Navigation to implement a complete app flow.

This is the second article of a 3 part series — check out the other parts:

In the first article, we imagined a payment authorisation flow for a mobile banking app:

Realising we would have to mix timers, network calls, native API calls and view state if we implemented the screens directly, we abstracted the business logic for the flow in a state machine…

State machine for a payment authorisation flow
You can try the state machine with the XState Visualizer

…which we implemented with XState (gist). Now, we need to actually use the state machine to orchestrate the transition from one screen of the authorisation flow to the next.

The idea is to watch the current state of the state machine for changes, and navigate to the corresponding screen. The state machine itself will take care of orchestrating side effects related to API calls and the authorisation timer.

Let’s get our hands dirty!

Setup the navigation stack

We should start by creating the navigation stack for the screens with React Navigation:

const PaymentAuthorizationStack = createStackNavigator();function PaymentAuthorizationScreen() {
// ...
return (
<PaymentAuthorizationStack.Navigator>
<PaymentAuthorizationStack.Screen
name="Start"
component={StartScreen}
/>
<PaymentAuthorizationStack.Screen
name="AuthorizationBiometric"
component={AuthorizationBiometricScreen}
/>
<PaymentAuthorizationStack.Screen
name="PaymentAuthorized"
component={PaymentAuthorizedScreen}
/>
<PaymentAuthorizationStack.Screen
name="AuthorizationDeclined"
component={AuthorizationDeclinedScreen}
/>
<PaymentAuthorizationStack.Screen
name="AuthorizationError"
component={AuthorizationErrorScreen}
/>
</PaymentAuthorizationStack.Navigator>
);
}
  • StartScreen is the initial screen of the stack; it displays a loading indicator while the machine checks authorisation prerequisites (e.g. biometric factor is enrolled) and fetches the payment details;
  • AuthorizationBiometric is the second screen; it handles the keychain access through biometric factor and displays a loading indicator while the payment authorisation is in flight;
  • PaymentAuthorized is the final screen of the happy path; it displays a success message;
  • AuthorizationDeclined is the screen that gets displayed when the user declines the payment; it displays a call to action to contact the call center of the bank;
  • AuthorizationError is a screen that handles the error states in the flow, displaying an error message depending on the case.

We also need to start the state machine when we enter the payment authorisation stack. XState provides a set of hooks to integrate state machines with React in the @xstate/react package; the useMachine hook interprets the state machine:

import { useMachine } from "@xstate/react";
import machine from "../machine";
function PaymentAuthorizationScreen() {
const [state, send, service] = useMachine(machine, {
services: {
checkPrerequisites: async () => {/* ... */},
fetchPaymentDetails: async () => {/* ... */},
authorizePayment: async () => {/* ... */},
}
});
return (
<PaymentAuthorizationStack.Navigator>
...
</PaymentAuthorizationStack.Navigator>
);
}

Notice that we can inject the required services, further decoupling the state machine definition from the actual interpretation. This could be handy if we need to reuse a machine with different service implementations and makes it possible to stub services during tests.

The useMachine hook returns a 3 element tuple:

  1. The first element of the tuple is the current state;
  2. The second element is a callback to send events to the machine, to trigger state transitions;
  3. The final element is the service returned by XState when interpreting the machine; it will be useful to listen for and react to state transitions.

We will use this tuple to render the appropriate view depending on the state. To access it more conveniently, we can also wrap the navigation stack in a context:

const PaymentAuthMachineContext = React.createContext(null);function PaymentAuthorizationScreen() {
const authMachine = useMachine(machine, {
services: {
checkPrerequisites: async () => {/* ... */},
fetchPaymentDetails: async () => {/* ... */},
authorizePayment: async () => {/* ... */},
}
});
return (
<PaymentAuthMachineContext.Provider value={authMachine}>
<PaymentAuthorizationStack.Navigator>
...
</PaymentAuthorizationStack.Navigator>
</PaymentAuthMachineContext.Provider>
);
}

Navigate to the next screen when state changes

The idea is to watch the current state of the state machine for changes, and navigate to the corresponding screen.

For some state changes, we want to navigate to a different screen in our stack. For example, if we are in the checkPrerequisites state and the device has no biometric factor enrolled, the state machine will transition to the prerequisitesNotMet state; accordingly, the app should navigate to the AuthorizationError screen with the appropriate error message.

We can leverage the service returned from the interpreter to listen for state changes:

function StartScreen({ navigation }) {
const [state, send, service] = useContext(
PaymentAuthorizationMachineContext
);
useEffect(() => {
const subscription = service.subscribe((state) => {
if (state.matches("prerequisitesNotMet")) {
navigation.navigate(
"AuthorizationError", {
message: "Cannot authorise payments on this device.",
});
}
}

return subscription.unsubscribe;
}, [service, navigation]);
return <SafeAreaView>{/* ... */}</SafeAreaView>
}

Besides, we probably want to display a loading indicator while asynchronous calls are being resolved; it is enough to derive the loading state from the machine states we consider to be loading:

function StartScreen({ navigation }) {
const [state, send, service] = useContext(
PaymentAuthorizationMachineContext
);
// ...useEffect... const isLoading = state.matches("checkPrerequisites")
|| state.matches("fetchingPaymentDetails");
return (
<SafeAreaView>
{
isLoading
? <ActivityIndicator animating />
: <View>{/* ... */}</View>
}
</SafeAreaView>
);
}

Access the machine’s context

Finally, we can access the machine’s context to derive component state from the machine current state. For example, we need to access the expiresOn date to display an expiration timer:

function PaymentAuthorizationTimer() {
const [state, send, service] = useContext(
PaymentAuthorizationMachineContext
);
const navigation = useNavigation();
useEffect(() => {
const subscription = service.subscribe((state) => {
if (state.matches("authorizationExpired")) {
navigation.navigate(
"AuthorizationError", {
message: "Cannot authorise payments on this device.",
});
}
}
return subscription.unsubscribe;
}, [service, navigation]);
return state.context.expiresOn ? (
<Timer expiresOn={state.context.expiresOn} />
) : null;

};

We not only encapsulate the logic for displaying the timer but also the logic to transition to the AuthorizationError screen when the timer expires. In this way, we can reuse the PaymentAuthorizationTimer in multiple screens without repeating the logic for the navigation in each of them.

Putting it all together

If you want to play around with a complete implementation, I have published it in a GitHub repository. Don’t feel like going through all the setup? I have got you covered, check out this Expo Snack!

N.B: subscribing to the service to listen for state changes becomes boilerplate-y in the long run. The logic could be encapsulated in a custom hook to mitigate this, but I wanted to keep the example as “bare metal” as possible.

Another approach could be to encode the navigations as XState actions, and configure them in the second argument of the useMachine hook.

Bonus: divide et impera

By leveraging hierarchical states we can create a state machine that can be nested in the original one.

If you check the code on GitHub, you may notice that the implementation is simple for all but the AuthorizationBiometricScreen. We are interacting with asynchronous APIs outside of the state machine, and need to fallback to manage the loading/error states with React states and effects. On the other hand, if we add more states to the machine it will become unwieldy and hard to understand.

We can get back on track by leveraging hierarchical states, creating a state machine that can be nested in the original one.

As an example, in the following gist I have modelled a biometric authentication flow where the user can attempt to authenticate at most 3 times:

There are a bunch of XState concepts you should understand to fully grasp the above state; you may want to read about actions and internal transitions.

This state can be nested in the parent state of the original machine:

const paymentAuthorizationMachine = Machine({
// ...
paymentDetailsFetched: {
on: {
DISMISS: "authorizationDismissed",
KEYCHAIN_ACCESS_OK: "authorizingPayment",
KEYCHAIN_ACCESS_KO: "biometricFactorError",
},
// nest the states
...keychainAccessStates,
}
// ...
});

Once more, we were able to orchestrate complex business logic in very little declarative code which is completely decoupled from the corresponding screen!

Conclusion

Integrating XState and React Navigation helps to keep the screens implementation simple.

Furthermore, we can scale the state machine as it grows by splitting it into nested substates, encapsulating API calls, the logic for retries, and more.

In the next article, we will test the state machine. I will show that we can easily stub services and verify that the machine behaves as expected. This will allows us to fully focus on the presentation logic when testing the screens.

--

--

Simone D'Avico
WellD Tech

Software Engineer with a passion for programming languages