
TL;DR
If you just want to see the code, it ishere.
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 ourappMachine
. 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 theappMachine
, form the tree with its first branches.
In theappMachine
'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 theinput
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 theappMachine
and theappProvider
is introduced:
exportconstAppContext=createActorContext(appMachine);exportfunctionAppProvider({children}:React.PropsWithChildren<unknown>){return<AppContext.Provider>{children}</AppContext.Provider>;}exportfunctionuseApp(){constactorRef=AppContext.useActorRef();conststate=useSelector(actorRef,(snapshot)=>{returnsnapshot;});return{state,send:actorRef.send,};}
And then we can consume the data from a react view:
exportdefaultReact.memo(functionHome({}:Props){const{state}=useApp();return(<View><Text>Welcome,{state.context.username}</Text></View>);});
It's worth mentioning that there is anotherapproach 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 isReact 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 thatappMachine
carries a special role when integrating with React Native and the same is valid for React Navigation.
First, we set ournavigationRef
. 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, thenavigationRef
has different build-in methods than the standardnavigation
object.
exportfunctionNavigation(){const{send}=useApp();return(<NavigationContaineronReady={()=>{send({type:"START_APP"});}}ref={navigationRef}><RootNavigator/></NavigationContainer>);}
We use theonReady
listener toinitialize
theappMachine
. This way we are sure that we already have thenavigationRef
setup, which we will be using in a bit.
Following what we have so far, we set ourappMachine
to be consumed by ourRootNavigator
. 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 asprop
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.
exportfunctionRootNavigator(){const{state}=useApp();constisAuthenticating=state.matches("authenticating");constisAuthenticated=state.matches("authenticated");return(<Stack.NavigatorscreenOptions={{headerShown:false}}>{isAuthenticating&&(<Stack.Screenname="Authenticating">{(props)=>{returnstate.context.refAuthenticating?(<AuthenticatingNavigatoractorRef={state.context.refAuthenticating}{...props}/>):null;}}</Stack.Screen>)}{isAuthenticated&&(<Stack.Screenname="Authenticated">{(props)=>{returnstate.context.refAuthenticated?(<AuthenticatedNavigatoractorRef={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 theReact Navigation
declarative approach, we might loose some built in functionalities likegoBack
. Also, components likeBottom 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 forReact 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 againnavigationRef
comes to the rescue.
import{createNavigationContainerRef}from"@react-navigation/native";exportconstnavigationRef=createNavigationContainerRef();exportfunctiongetCurrentRouteName(){if(navigationRef.isReady()){returnnavigationRef.getCurrentRoute()?.name;}else{returnundefined;}}
Now we can prepare our hook that will be listening for the route changes.
exportfunctionuseNavigator<T>(callback:(route:keyofT)=>void,initialRoute?:keyofT,){constprevRoute=useRef<string|undefined>();useEffect(()=>{constunsubscribe=navigationRef.addListener("state",(_event)=>{constscreenRoute=getCurrentRouteName();if(screenRoute&&prevRoute.current!==screenRoute){prevRoute.current=screenRoute;callback(screenRouteaskeyofT);}elseif(!screenRoute&&initialRoute){callback(initialRoute);}});returnunsubscribe;},[]);}
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 dispatchednavigate
. This way we synchronise the focused screen and the machine state.
exportfunctionAuthenticatedNavigator({actorRef}:Props){conststate=useSelector(actorRef,(snapshot)=>{returnsnapshot;});useNavigator<AuthenticatedParamList>((route)=>{actorRef.send({type:"NAVIGATE",screen:route});});return(<Stack.NavigatorinitialRouteName="Home"><Stack.Screenname="Home">{(props)=>{return<HomeScreenactorRef={state.context.refHome}{...props}/>;}}</Stack.Screen><Stack.Screenname="List">{(props)=>{return<ListScreenactorRef={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 asprop
to the screen component and used withuseSelector
from there.
conststate=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 fromcreateModel
totypegen
, 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 thedynamic params
in v5, I still have mixed feeling about ditchingtypegen
. 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:
isHomeScreen(_,params:{screen:keyofAuthenticatedParamList}){returnparams.screen==="Home";},
to
isHomeScreen({context},params:{screen:keyofAuthenticatedParamList}){returnparams.screen==="Home"&&context.isUserAllowed;},
Still, I personally prefer to protect routes at navigator level (in theuseApp.tsx
).
Let me know if this makes sense to you.
For further actions, you may consider blocking this person and/orreporting abuse