React Native authentication with xState v5

gtodorov

Georgi Todorov

Posted on November 16, 2024

React Native authentication with xState v5

TL;DR

If you just want to see the code, it is here. And this is the PR with the latest changes that are discussed in the post.

Background

This topic wasn’t my original plan for continuing the series. However, to keep the example aligned with a real-world application, I felt it was necessary to improve the authentication mechanism in terms of usability and persistence.

Disclaimer

My experience with mobile authentication is primarily focused on Firebase. To ensure consistency with its API, I’ve added a few methods to our example that mimic the lifecycle of a user session:

  • onAuthStateChanged - An observer that accepts a function. When a user logs in, their object is returned to the callback.
  • getCurrentUser - A synchronous method that returns the user object if there’s an active session; otherwise, it returns null.
  • signInWithPhone - This method accepts a phone number as an argument and initiates a user session on the backend.
  • signOut - Pretty self-explanatory. Ends the user session.

You can find their naive implementation in the api.ts file.

To ensure the data persistency, I've used react-native-mmkv.
Its synchronous methods provide a great developer experience, and the addOnValueChangedListener helped me greatly with the user session logic.

Sign in

Firstly, we start with enhancing the Authenticating screen with <TextInput/> for the user to enter their phone number. This is used as a controlled input, with the phone number value stored in the authenticating machine's context.

// ...
<TextInput
  mode="outlined"
  label="Phone number"
  value={phoneNumber}
  keyboardType="phone-pad"
  onChangeText={(value) => {
    setPhoneNumber(value);
  }}
  style={{ marginBottom: 16, width: "100%" }}
/>
<Button
  mode="contained"
  loading={isLoading}
  onPress={() => {
    onSignInPress();
  }}
>
  Sign In
</Button>
// ...
Enter fullscreen mode Exit fullscreen mode

Once we click the Sign In button, we invoke the signInWithPhone method with the stores phone number, which triggers the onAuthStateChanged listener.

Since we are dealing with a listener, it’s convenient to use a callback actor. We place the actor at the root level of the authenticatingMachine to ensure it listens for changes throughout the machine’s lifespan.
When the callback actor receives user data, it knows a user session is active. At this point, the SIGN_IN event is sent to the parent machine (appMachine). Then the machine transitions to the authenticated state, which renders the stack.

At this stage, the application displays the home screen.

export const authenticatingMachine = setup({
  types: {
    context: {} as { phoneNumber: string },
    events: {} as
      | { type: "SIGN_IN" }
      | { type: "SET_SIGNED_IN_USER"; user: User }
      | { type: "NAVIGATE"; screen: keyof AuthenticatingParamList }
      | { type: "SET_PHONE_NUMBER"; phoneNumber: string },
  },
  actors: {
    signIn: fromPromise(
      async ({ input }: { input: { phoneNumber: string } }) => {
        const result = await signInWithPhone(input.phoneNumber);
        return result as { status: string; user: User };
      },
    ),
    userSubscriber: fromCallback(({ sendBack }) => {
      const subscriber = onAuthStateChanged((user) => {
        if (user) {
          sendBack({ type: "SET_SIGNED_IN_USER", user });
        }
      });

      return subscriber.remove;
    }),
  },
  actions: {
    sendParentSignIn: sendParent(
      (_, { user: { phoneNumber } }: { user: User }) => {
        return {
          type: "SIGN_IN",
          username: phoneNumber,
        };
      },
    ),
    setPhoneNumber: assign({
      phoneNumber: (_, params: { phoneNumber: string }) => {
        return params.phoneNumber;
      },
    }),
  },
}).createMachine({
  id: "authenticating",
  initial: "idle",
  context: { phoneNumber: "" },
  invoke: {
    src: "userSubscriber",
  },
  on: {
    SET_SIGNED_IN_USER: {
      actions: [
        {
          type: "sendParentSignIn",
          params: ({ event }) => {
            return { user: event.user };
          },
        },
      ],
    },
  },
  states: {
    idle: {
      on: {
        SIGN_IN: {
          target: "signingIn",
        },
        SET_PHONE_NUMBER: {
          actions: [
            {
              type: "setPhoneNumber",
              params: ({ event }) => {
                return { phoneNumber: event.phoneNumber };
              },
            },
          ],
        },
      },
    },
    signingIn: {
      invoke: {
        src: "signIn",
        input: ({ context }) => {
          return { phoneNumber: context.phoneNumber };
        },
        onDone: {
          target: "idle",
        },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Persistency

The updated authentication paradigm introduces the ability to persist user credentials. Using the getCurrentUser method, we can check for user availability every time the app launches. If an active user is returned, the app navigates directly to the home screen.

In the context of xState, this is implemented with a guard and an eventless (always) transition.

//...
guards: {
  isUserAuthenticated() {
    return getCurrentUser() !== null;
  },
},
//...
initializing: {
  entry: "setRefNotificationCenter",
  on: { START_APP: { target: "authenticating" } },
  always: [
    {
      guard: "isUserAuthenticated",
      target: "authenticated",
      actions: [
        {
          type: "setUsername",
          params: () => {
            return { username: getCurrentUser()?.phoneNumber ?? "" };
          },
        },
      ],
    },
    { target: "authenticating" },
  ],
}
//...
Enter fullscreen mode Exit fullscreen mode

Sign out

To allow users to log out, we add a logout button in the <Appbar.Header/> of the authenticated.navigator. This ensures it’s visible only to registered users.

<Stack.Navigator
  initialRouteName="Home"
  screenOptions={{
    header: (props) => {
      return (
        <Appbar.Header>
          {props.back ? (
            <Appbar.BackAction onPress={props.navigation.goBack} />
          ) : null}
          <Appbar.Content title={props.route.name} />
          <Appbar.Action
            icon="logout"
            onPress={() => {
              actorRef.send({ type: "SIGN_OUT" });
            }}
          />
        </Appbar.Header>
      );
    },
  }}
>
Enter fullscreen mode Exit fullscreen mode

Clicking the button sends the SIGN_OUT event to the appMachine, which calls the signOut method to end the session. When the method is done, the machine transitions to the authenticating state, conditionally rendering the corresponding navigator and ensuring protected screens are no longer accessible.

Animation of basic authentication flow

Conclusion

As mentioned in previous posts, I enjoy using callback actors with Firebase/Firestore. With XState 5, actor reusability has become even more straightforward.
I’ve planned a few more posts for the coming months, but feel free to reach out if there’s something specific you’d like me to cover.

💖 💪 🙅 🚩
gtodorov
Georgi Todorov

Posted on November 16, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

React Native authentication with xState v5
reactnative React Native authentication with xState v5

November 16, 2024

React Native with xState v5
reactnative React Native with xState v5

May 9, 2024