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 returnsnull
. -
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>
// ...
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",
},
},
},
},
});
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" },
],
}
//...
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>
);
},
}}
>
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.
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.
Top comments (0)