DEV Community

Cover image for Real-time data with xState, Firebase and Next.js App Router
Georgi Todorov
Georgi Todorov

Posted on

Real-time data with xState, Firebase and Next.js App Router

TL;DR

If you just want to see the code, it is here. You can also have a look at the xState 4 implementation here.

Background

After successfully implementing the authentication flow with Firebase, my next focus for the web app is real-time updates, which I intend to achieve using Cloud Firestore.

Use case

Building upon our existing codebase, we will add functionalities for logged-in users. This includes creating a mechanism for writing and reading messages from the database, with real-time updates.

Disclaimer

For simplicity, the message text will be hardcoded. Form input and validation will be covered in a future blog post. After fetching the messages list, we will only display the number of messages.

Setting data in Firestore

The messages will consist of two properties: text and sender. The more interesting property is sender, which will hold the uid of the authenticated Firebase user who created the message.

interface Message {
  sender: string;
  text: string;
}
Enter fullscreen mode Exit fullscreen mode

In the userSubscriber actor, we will extract the uid from the user parameter and pass it to the GO_TO_AUTHENTICATED event. After adding this transition, we can introduce a setUid action that reads it from the event object and sets it in the machine's context.

const appMachine = setup({
  /* ... */
  actions: {
    setUid: assign({
      uid: ({ event }) => {
        assertEvent(event, "GO_TO_AUTHENTICATED");
        return event.uid;
      },
    }),
    /* ... */
  },
  actors: {
    userSubscriber: fromCallback(({ sendBack }) => {
      const unsubscribe = onAuthStateChanged(auth, (user) => {
        if (user) {
          sendBack({ type: "GO_TO_AUTHENTICATED", uid: user.uid });
        } else {
          sendBack({ type: "GO_TO_UNAUTHENTICATED" });
        }
      });
      return () => unsubscribe();
    }),
    /* ... */
  },
}).createMachine({
  context: { uid: ""},
  invoke: { src: "userSubscriber" },
  on: {
    GO_TO_AUTHENTICATED: {
      actions: ["setUid"],
      target: ".authenticated",
    },
    /* ... */
  },
  initial: "loading",
  states: {
    loading: { tags: "loading" },
    authenticated: {
      /* ... */
    },
    /* ... */
  },
});
Enter fullscreen mode Exit fullscreen mode

Now, we can proceed with creating the actual message. First, we need UI elements that trigger the event. In the root page, we add a button that initiates the process:

<button
  className="button"
  onClick={() => {
    actorRef.send({ type: "ADD_MESSAGE" });
  }}
>```
{% endraw %}


We need to implement the {% raw %}`ADD_MESSAGE`{% endraw %} event within the {% raw %}`authenticated`{% endraw %} state to ensure it's available only to existing users. This event targets the {% raw %}`.addingMessage`{% endraw %} substate, which, in turn, invokes the {% raw %}`addMessage`{% endraw %} actor.

We use the {% raw %}`setDoc`{% endraw %} Firestore method and we want our newly created messages to be organized in a messages collection. Each message will be a document with its id based on the current time in milliseconds. However, it's important to note that this approach is not recommended for production as there is still a technical chance of duplicating IDs. The message itself consists of a {% raw %}`text`{% endraw %} value (currently hardcoded) and a {% raw %}`sender`{% endraw %} that reads the authenticated user's ID from the context.

Don't forget to set your database access permissions.
{% raw %}


```typescript
import firebaseApp from "@/firebase";

/* ... */
const firestore = getFirestore(firebaseApp);

const appMachine = setup({
  /* ... */
  actors: {
    /* ... */
    addMessage: fromPromise(async ({ input }: { input: { uid: string } }) => {
      await setDoc(doc(firestore, "messages", `${Date.now()}`), {
        sender: input.uid,
        text: "Lorem Ipsumm",
      });
    }),
    /* ... */
  },
}).createMachine({
  /* ... */
  initial: "loading",
  states: {
    loading: { tags: "loading" },
    authenticated: {
      /* ... */
      on: {
        ADD_MESSAGE: { target: ".addingMessage" },
        /* ... */
      },
      initial: "idle",
      states: {
        idle: {},
        /* ... */
        addingMessage: {
          invoke: {
            src: "addMessage",
            input: ({ context }) => {
              return { uid: context.uid };
            },
            onDone: { target: "idle" },
            onError: {
              actions: ["logError"],
            },
          },
        },
      },
    },
    /* ... */
  },
});
Enter fullscreen mode Exit fullscreen mode

Reading data from Firestore

With messages now being written to the database, we can proceed to demonstrate the power of Firestore in real-time updates.

To do this, we use the onSnapshot method, which attaches listeners to the parts of the database we need to monitor for changes and returns the newly updated data.

Similar to the onAuthStateChanged listener, we utilize the onSnapshot method in a actor callback. From there, we can set the retrieved messages in the context by triggering the SET_MESSAGES. This allows us to easily subscribe to the messages collection and provide the latest information to all authenticated users.

const appMachine = setup({
  /* ... */
  actions: {
    /* ... */
    setMessages: assign({
      messages: ({ event }) => {
        assertEvent(event, "SET_MESSAGES");
        return event.messages;
      },
    }),
    /* ... */
  },
  actors: {
    /* ... */
    messagesSubscriber: fromCallback(({ sendBack }) => {
      const unsubscribe = onSnapshot(
        query(collection(firestore, "messages")),
        (querySnapshot) => {
          const messages: Message[] = [];
          querySnapshot.forEach((doc) => {
            messages.push(doc.data() as Message);
          });

          sendBack({ type: "SET_MESSAGES", messages });
        }
      );

      return () => unsubscribe();
    }),
    /* ... */
  },
}).createMachine({
  context: { messages: [] },
  /* ... */
  initial: "loading",
  states: {
    loading: { tags: "loading" },
    authenticated: {
      invoke: { src: "messagesSubscriber" },
      on: {
        SET_MESSAGES: { actions: ["setMessages"] },
        /* ... */
      },
      initial: "idle",
      states: {
        idle: {},
        /* ... */
      },
    },
    /* ... */
  },
});
Enter fullscreen mode Exit fullscreen mode

From this point, you can display your messages in a way that suits your application. In our case, we choose to display only the number of messages in the collection next to the Add message button.

const [state, send] = AppContext.useActor();

<span>{state.context.messages.length}</span>
Enter fullscreen mode Exit fullscreen mode

Conclusion

Overall, this example sets the foundation for building real-time applications that react to dynamic data changes. From what I've experienced, the possibilities for expanding and refining this setup are extensive, making it a solid choice for developing interactive web applications.

👋 While you are here

Reinvent your career. Join DEV.

It takes one minute and is worth it for your career.

Get started

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Discover a treasure trove of wisdom within this insightful piece, highly respected in the nurturing DEV Community enviroment. Developers, whether novice or expert, are encouraged to participate and add to our shared knowledge basin.

A simple "thank you" can illuminate someone's day. Express your appreciation in the comments section!

On DEV, sharing ideas smoothens our journey and strengthens our community ties. Learn something useful? Offering a quick thanks to the author is deeply appreciated.

Okay