Authentication with xState, Firebase and Next.js
Published: 2023-09-29 16:47:03

### TL;DR
If you just want to see the code, it is [here](https://github.com/g-todorov/xstate-authentication-example).

### Background

In our team, we've been using xState and Firebase for the past year for the development of a React Native application, and the time for a web version is drawing closer. Although we haven't officially settled on a framework yet, Next.js 13 is definitely on the table, and I decided that it's worth spending some time experimenting with the basic techniques and workflows so that we can make an informed decision. To start with the authentication seems like the obvious choice.

### Use case 

To experiment with the authentication capabilities of the stack, we will build a simple website with two pages: a `Sign In` screen and a `Dashboard` page accessible only to authenticated users.

### Disclaimers

Integrating Firebase in Next.js is quite straightforward and covered in enough online materials, so we won't discuss it in this post. The focus will be on integrating both technologies with xState. Just for clarity, here's the Firebase config file:

```typescript
import { initializeApp, getApps } from "firebase/app";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_firebaseApp_ID,
};

let firebaseApp =
  getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];

export default firebaseApp;
```

Also, to simplify things further, we will use the `signInAnonymously` method, which works the same way as authenticating with `email/password` or `phone` but doesn't require user input.

### Implementation

#### xState machine

Usually, when working with xState and React, I reach a point where I need a globally accessible `machine`. That's why I start by creating an `appMachine` and pass it to the `createActorContext` method so that it can be used from the React context. This way, we can keep the authentication logic in a single place and send events to/from any page.

Key parts of the machine are the root `GO_TO_AUTHENTICATED` and `GO_TO_UNAUTHENTICATED` events. They lead the user to the correct state and, respectively, to the correct screen. They are intentionally flagged as `internal` to prevent infinite loops.

The `userSubscriber` service (which will be explained in a bit) plays the role of an orchestrator, which is in charge of listening for the user object from Firebase and targeting the appropriate state with the one of the already mentioned events.

The machine consists of three main states. `loading` is the initial state that is active until we know the user's status. After that, we transition to one of the other two states - `authenticated` or `unauthenticated`. They both have substates and are `idle` initially. When the user is `authenticated`, they can call the `SIGN_OUT` event to transition to the `signingOut` substate, which is in charge of invoking the `signOut` method. The `unauthenticated` structure is similar, with the difference that instead of signing out, it contains the signing-in logic.

```typescript
import React, { PropsWithChildren } from "react";
import { createMachine } from "xstate";
import { createActorContext } from "@xstate/react";

const appMachine = createMachine({
  invoke: { src: "userSubscriber" },
  on: {
    GO_TO_AUTHENTICATED: { target: "authenticated", internal: true },
    GO_TO_UNAUTHENTICATED: { target: "unauthenticated", internal: true },
  },
  initial: "loading",
  states: {
    loading: { tags: "loading" },
    authenticated: {
      on: { SIGN_OUT: { target: ".signingOut" } },
      initial: "idle",
      states: {
        idle: {},
        signingOut: {
          invoke: { src: "signOut" },
          onDone: { target: "idle" },
        },
      },
    },
    unauthenticated: {
      on: { SIGN_IN: { target: ".signingIn" } },
      initial: "idle",
      states: {
        idle: {},
        signingIn: { invoke: { src: "signIn" }, onDone: { target: "idle" } },
      },
    },
  },
  {
  /* machine options */
  }
});

export const AppContext = createActorContext(appMachine);

export function AppProvider({ children }: PropsWithChildren<{}>) {
  return <AppContext.Provider>{children}</AppContext.Provider>;
}
```

#### Firebase 

In order to retrieve the current user, Firebase recommends using the `onAuthStateChanged` observer, which is a perfect fit for a `callback service`. That simplifies our work drastically. In the callback, we just have to check the `user` value. If it is `null`, the user is unauthenticated; otherwise, we trigger the `GO_TO_AUTHENTICATED` event.

For the `signIn` service, as mentioned before, we go with the `signInAnonymously` method, and for the `signOut` service, we resolve the `auth.signOut()` promise. Both of these will reflect on the `user` that is being observed in the `userSubscriber` service.

```typescript
import { onAuthStateChanged, getAuth, signInAnonymously } from "firebase/auth";
import firebaseApp from "@/firebase";

const auth = getAuth(firebaseApp);

const appMachine = createMachine(
  {
  /* machine definition */
  },
  {
    services: {
      userSubscriber() {
        return (sendBack) => {
          const unsubscribe = onAuthStateChanged(auth, (user) => {
            if (user) {
              sendBack({ type: "GO_TO_AUTHENTICATED" });
            } else {
              sendBack({ type: "GO_TO_UNAUTHENTICATED" });
            }
          });
          return () => unsubscribe();
        };
      },
      async signIn() {
        await signInAnonymously(auth);
      },
      async signOut() {
        await auth.signOut();
      },
    },
  }
);
```

#### Next.js 

From here, we can continue by consuming the context. Wrapping the `children` with `AppProvider` in the `RootLayout` gives access to the global machine from all layouts and pages.

```typescript
import { AppProvider } from "@/contexts/app";
import { Loader } from "@/components/Loader";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <AppProvider>
          <Loader>{children}</Loader>
        </AppProvider>
      </body>
    </html>
  );
}
```

The purpose of the `<Loader>` component is to prevent pages from rendering before the user data is loaded. The `AppContext.useActor()` hook always updates on state changes, and when the state is `loading`, we just display a placeholder screen.

```typescript
"use client";

import { PropsWithChildren } from "react";
import { AppContext } from "@/contexts/app";

export function Loader({ children }: PropsWithChildren<{}>) {
  const [state] = AppContext.useActor();

  return state.matches("loading") ? (
    <main>
      <div>Loading...</div>
    </main>
  ) : (
    children
  );
}
```

Once one of the `authenticated` or `unauthenticated` states is active, the corresponding page will be loaded. If the user already exists, they will be navigated to the dashboard, which is located at the root - `/`. Otherwise, they should be redirected to the `/sign-in` page.

From my past experience, mixing xState with the navigation lifecycle of another framework is one of the most troublesome parts when setting the initial application architecture.

I had a couple of ideas for different compositions before settling on the current implementation, but I found that I have minimal control when navigating with `useRouter()` in the new `App Router` architecture.

I wanted to trigger an event when the `router.push()` method is done, but it turned out that it no longer returns a promise. Also, the alternative of using the `router.events` didn't provide the fine control that I expected, so there was again flickering when navigating between pages.

Despite my efforts to abstract the routing in a single component or context, I ended up using `protected routes`. They never scale well for me, but I think they are sufficient in this scenario.

In order to prevent premature loading of the screen, we assume with the `isRedirecting` flag that the user is on the wrong page until proven wrong. We listen for `state.value` changes and redirect the user to the `sign-in` url if the state is `unauthenticated`.

In order to prevent premature loading of the screen, we assume with the `isRedirecting` flag that the user is on the wrong page until proven otherwise. We listen for `state.value` changes and redirect the user with the `sign-in` href if the state is `unauthenticated`.

```typescript
export default function Home() {
  const [state, send] = AppContext.useActor();
  const router = useRouter();
  const [isRedirecting, setIsRedirecting] = useState(true);

  useEffect(() => {
    if (state.matches("unauthenticated")) {
      router.push("/sign-in");
    } else if (state.matches("authenticated")) {
      setIsRedirecting(false);
    }
  }, [state.value]);

  if (isRedirecting) {
    return null;
  }

  return (
    <main>
      <h3>Dashboard:</h3>
      <button
        onClick={() => {
          send({ type: "SIGN_OUT" });
        }}
      >
        {state.matches("authenticated.signingOut") ? "...loading" : "sign out"}
      </button>
    </main>
  );
}
```

### Conclusion

From the little that I tried out, I'm satisfied with the results so far, but I still have concerns about how the application will grow when adding more pages and interactions with Firebase.