TL;DR
If you just want to see the code, it is here.
Disclamer
This not an introduction tutorial for xState nor React Native. It requires some basic knowledge of the libraries and won't be explaining the example code line by line. You can consider it as an opinionated guide on how to structure your application.
Background
In our team we recently released a React Native application that heavily relies on xState. As xState v5 is advancing, we decided that the time for migration from v4 has come. Since, we are already in production, we want to design it properly and use the chance to introduce some improvements along with the upgrade.
As I'm in charge of the migration, I'm planning to use this post/series as both playground and documentation. More features will be added eventually, until most of our xState cases are mimicked with version 5. I'm not going to make a comparison between v4 and v5, but instead I will set a brand new project with the latest version. Hopefully, sharing some of our key principles when working together with React Native and xState, will bring constructive feedback.
State orchestration
Working with xState and React Native, I usually build my app by integrating my view library in xState, instead of the other way round. This might feel as an overkill but I like the control it gives me over the application and how business logic organically finds its proper place in the machines, leaving the screens/components stateless.
I like to form the app around a treelike machine structure by using the actor model that xState adapts. The root is our appMachine
. Its states form the flow of the application. Each state has a dedicated spawned actor that is assigned in the machine context. These actors, along with the appMachine
, form the tree with its first branches.
In the appMachine
's context, except the actor references, we can also keep data that is considered global for the application (profile data, languages settings, etc.).
Once having it there, we can pass it to the spawned actor children via the input
prop.
On the other hand we might need the global data just for visual purposes as displaying profile settings, which makes passing it around the spawned children useless. That's why, along with the appMachine
and the appProvider
is introduced:
export const AppContext = createActorContext(appMachine);
export function AppProvider({ children }: React.PropsWithChildren<unknown>) {
return <AppContext.Provider>{children}</AppContext.Provider>;
}
export function useApp() {
const actorRef = AppContext.useActorRef();
const state = useSelector(actorRef, (snapshot) => {
return snapshot;
});
return {
state,
send: actorRef.send,
};
}
And then we can consume the data from a react view:
export default React.memo(function Home({}: Props) {
const { state } = useApp();
return (
<View>
<Text>Welcome, {state.context.username}</Text>
</View>
);
});
It's worth mentioning that there is another approach for dealing with global state.
Navigation
In order to give meaning to our application flow, we need to visualize what our tree represents. We can achieve this by introducing a navigation instrument and my weapon of choice is React Navigation
.
I found this part to be the hardest to implement but the solution has proven with time.
Following the diagram, I'll try to explain how the actor system is working with the navigation.
I've already mentioned that appMachine
carries a special role when integrating with React Native and the same is valid for React Navigation.
First, we set our navigationRef
. Despite it not the recommended way of navigating through screens, it might be convenient. It is really useful when you want to navigate from your machine after asynchronous actor has finished. Also, the navigationRef
has different build-in methods than the standard navigation
object.
export function Navigation() {
const { send } = useApp();
return (
<NavigationContainer
onReady={() => {
send({ type: "START_APP" });
}}
ref={navigationRef}
>
<RootNavigator />
</NavigationContainer>
);
}
We use the onReady
listener to initialize
the appMachine
. This way we are sure that we already have the navigationRef
setup, which we will be using in a bit.
Following what we have so far, we set our appMachine
to be consumed by our RootNavigator
. As mentioned earlier, each root state has an actor reference that is spawned when entering the state and stopped when exiting the state. These actors become our navigator machines. They will respectively be passed as prop
s to their own <Stack.Navigator/>
. The important bit from here is that only one <Stack.Navigator/>
can exist at a time, depending on the root machine state, which works great for protecting routes.
export function RootNavigator() {
const { state } = useApp();
const isAuthenticating = state.matches("authenticating");
const isAuthenticated = state.matches("authenticated");
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
{isAuthenticating && (
<Stack.Screen name="Authenticating">
{(props) => {
return state.context.refAuthenticating ? (
<AuthenticatingNavigator
actorRef={state.context.refAuthenticating}
{...props}
/>
) : null;
}}
</Stack.Screen>
)}
{isAuthenticated && (
<Stack.Screen name="Authenticated">
{(props) => {
return state.context.refAuthenticated ? (
<AuthenticatedNavigator
actorRef={state.context.refAuthenticated}
{...props}
/>
) : null;
}}
</Stack.Screen>
)}
</Stack.Navigator>
);
}
When integrating xState with React Navigation, I see two possible approaches:
- xState driven navigation
- React Navigation driven navigation
Most of the examples/tutorials I've encountered, go with the first approach. Listening for changes in the machine state and then navigating to the corresponding page. Possibly, is the expected path when you build your app around xState. Here is a great example.
Despite that, after some experimentation, I found that this approach might lead to issues. With breaking the React Navigation
declarative approach, we might loose some built in functionalities like goBack
. Also, components like Bottom Tabs Navigator
tend to encapsulate the event handling, which means that extra development would be required in order to work properly with the paradigm. This led me thinking that this might not be the right direction.
On the other hand, listening for changes in the navigation state and updating your machine state accordingly, leaves all the heavy lifting for React Navigation
.
The implementation is not straightforward but can be gathered in several steps.
First, we need a method that gives us the current route, and again navigationRef
comes to the rescue.
import { createNavigationContainerRef } from "@react-navigation/native";
export const navigationRef = createNavigationContainerRef();
export function getCurrentRouteName() {
if (navigationRef.isReady()) {
return navigationRef.getCurrentRoute()?.name;
} else {
return undefined;
}
}
Now we can prepare our hook that will be listening for the route changes.
export function useNavigator<T>(
callback: (route: keyof T) => void,
initialRoute?: keyof T,
) {
const prevRoute = useRef<string | undefined>();
useEffect(() => {
const unsubscribe = navigationRef.addListener("state", (_event) => {
const screenRoute = getCurrentRouteName();
if (screenRoute && prevRoute.current !== screenRoute) {
prevRoute.current = screenRoute;
callback(screenRoute as keyof T);
} else if (!screenRoute && initialRoute) {
callback(initialRoute);
}
});
return unsubscribe;
}, []);
}
The hook is supposed to be used only in <Stack.Navigator/>
s. Each navigator's machine has a root event that handles the machine state change after the dispatched navigate
. This way we synchronise the focused screen and the machine state.
export function AuthenticatedNavigator({ actorRef }: Props) {
const state = useSelector(actorRef, (snapshot) => {
return snapshot;
});
useNavigator<AuthenticatedParamList>((route) => {
actorRef.send({ type: "NAVIGATE", screen: route });
});
return (
<Stack.Navigator initialRouteName="Home">
<Stack.Screen name="Home">
{(props) => {
return <HomeScreen actorRef={state.context.refHome} {...props} />;
}}
</Stack.Screen>
<Stack.Screen name="List">
{(props) => {
return <ListScreen actorRef={state.context.refList} {...props} />;
}}
</Stack.Screen>
</Stack.Navigator>
);
}
/* ... */
NAVIGATE: [
{
guard: {
type: "isHomeScreen",
params: ({ event }) => {
return {
screen: event.screen,
};
},
},
target: ".homeScreen",
},
{
guard: {
type: "isListScreen",
params: ({ event }) => {
return {
screen: event.screen,
};
},
},
target: ".listScreen",
},
],
/* ... */
In a similar manner as the navigator actors, each screen might have (or not) a dedicated actor reference which will be passed as prop
to the screen component and used with useSelector
from there.
const state = useSelector(actorRef, (snapshot) => snapshot);
Conclusion
Instead of praising how good xState is, I would like to leave couple of words what my first impressions are after I've started writing production code with v5.
As almost in the beginning of the project, I had a transition from createModel
to typegen
, I ensure you that I wasn't really keen on rewriting even larger codebase with new typing approach.
To be honest, though I feel the power of the dynamic params
in v5, I still have mixed feeling about ditching typegen
. Despite it introduces CI complexity, I feel like it was more of a helper than a burden.
On the other hand, typing actors/actions outside of the machine is something that I'm exited about.
Top comments (2)
Hi, with the approach in the article, is it possible to prevent route navigation based on the guards in the machine?
@arga_runchise If I understand you correctly, you can always make the guard stricter and add more authorization rules. For example:
to
Still, I personally prefer to protect routes at navigator level (in the
useApp.tsx
).Let me know if this makes sense to you.