DEV Community

Cover image for React Native with xState v5
Georgi Todorov
Georgi Todorov Subscriber

Posted on • Updated on

React Native with xState v5

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,
  };
}


Enter fullscreen mode Exit fullscreen mode

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>
  );
});


Enter fullscreen mode Exit fullscreen mode

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.

Image description

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>
  );
}



Enter fullscreen mode Exit fullscreen mode

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 props 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>
  );
}


Enter fullscreen mode Exit fullscreen mode

When integrating xState with React Navigation, I see two possible approaches:

  1. xState driven navigation
  2. 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;
  }
}


Enter fullscreen mode Exit fullscreen mode

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;
  }, []);
}


Enter fullscreen mode Exit fullscreen mode

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>
  );
}


Enter fullscreen mode Exit fullscreen mode


/* ... */
NAVIGATE: [
  {
    guard: {
      type: "isHomeScreen",
      params: ({ event }) => {
        return {
          screen: event.screen,
        };
      },
    },
    target: ".homeScreen",
  },
  {
    guard: {
      type: "isListScreen",
      params: ({ event }) => {
        return {
          screen: event.screen,
        };
      },
    },
    target: ".listScreen",
  },
],
/* ... */


Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode




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)

Collapse
 
arga_runchise profile image
Arga - Runchise

Hi, with the approach in the article, is it possible to prevent route navigation based on the guards in the machine?

Collapse
 
gtodorov profile image
Georgi Todorov

@arga_runchise If I understand you correctly, you can always make the guard stricter and add more authorization rules. For example:

    isHomeScreen(_, params: { screen: keyof AuthenticatedParamList }) {
      return params.screen === "Home";
    },
Enter fullscreen mode Exit fullscreen mode

to

    isHomeScreen({ context }, params: { screen: keyof AuthenticatedParamList }) {
      return params.screen === "Home" && context.isUserAllowed;
    },
Enter fullscreen mode Exit fullscreen mode

Still, I personally prefer to protect routes at navigator level (in the useApp.tsx).
Let me know if this makes sense to you.