TL;DR
If you just want to see the code, it is 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;
}
In the userSubscriber
service, 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 = createMachine(
{
context: { uid: "" },
/* ... */
invoke: { src: "userSubscriber" },
on: {
GO_TO_AUTHENTICATED: {
actions: ["setUid"],
target: "authenticated",
internal: true,
},
},
/* ... */
initial: "loading",
states: {
loading: { tags: "loading" },
authenticated: {
/* ... */
},
/* ... */
},
},
{
actions: {
setUid: assign({
uid: (context, event) => {
return event.uid;
},
}),
/* ... */
},
services: {
userSubscriber() {
return (sendBack) => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
if (user) {
sendBack({ type: "GO_TO_AUTHENTICATED", uid: user.uid });
} else {
sendBack({ type: "GO_TO_UNAUTHENTICATED" });
}
});
return () => unsubscribe();
};
},
/* ... */
},
}
);
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
onClick={() => {
send({ type: "ADD_MESSAGE" });
}}
>
"Add message"
</button>
We need to implement the ADD_MESSAGE
event within the authenticated
state to ensure it's available only to existing users. This event targets the .addingMessage
substate, which, in turn, invokes the addMessage
service.
We use the setDoc
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 text
value (currently hardcoded) and a sender
that reads the authenticated user's ID from the context.
Don't forget to set your database access permissions.
import firebaseApp from "@/firebase";
/* ... */
const firestore = getFirestore(firebaseApp);
const appMachine = createMachine(
{
/* ... */
initial: "loading",
states: {
loading: { tags: "loading" },
authenticated: {
invoke: { src: "messagesSubscriber" },
on: {
ADD_MESSAGE: { target: ".addingMessage" },
},
initial: "idle",
states: {
idle: {},
signingOut: {
invoke: { src: "signOut", onDone: { target: "idle" } },
},
addingMessage: {
invoke: {
src: "addMessage",
onDone: { target: "idle" },
onError: {
actions: ["logError"],
},
},
},
},
},
/* ... */
},
},
{
/* ... */
services: {
/* ... */
async addMessage(context, event) {
await setDoc(doc(firestore, "messages", `${Date.now()}`), {
sender: context.uid,
text: "Lorem Ipsumm",
});
},
},
}
);
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 service 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 = createMachine(
{
context: { messages: [] },
/* ... */
initial: "loading",
states: {
loading: { tags: "loading" },
authenticated: {
invoke: { src: "messagesSubscriber" },
on: {
/* ... */
SET_MESSAGES: { actions: ["setMessages"] },
},
initial: "idle",
states: {
idle: {},
/* ... */
},
},
/* ... */
},
},
{
actions: {
/* ... */
setMessages: assign({
messages: (context, event) => {
return event.messages;
},
}),
},
services: {
/* ... */
messagesSubscriber() {
return (sendBack) => {
const unsubscribe = onSnapshot(
query(collection(firestore, "messages")),
(querySnapshot) => {
const messages: Message[] = [];
querySnapshot.forEach((doc) => {
messages.push(doc.data().text);
});
sendBack({ type: "SET_MESSAGES", messages });
}
);
return () => unsubscribe();
};
},
/* ... */
},
}
);
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>
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.
Top comments (2)
Great article!
Looks like real time functionality of Firestore and xState play well with each other. When I first heard about this concept from firebase of communicating directly with your database from the UI, it was a bit strange, but it's very convenient for specific use cases.
Have you considered where to do any kind of validation or business logic before inserting records in database with this setup? On the client side? I know that firestore security rules (firebase.google.com/docs/firestore...) is another option.
Thanks for the comment!
To be honest, I'm not a big fan of communicating with the database from the client either. What I usually do is use a cloud function as a form of middleware that handles the validation and the write and delete database operations, but it felt like overkill for this post.
I probably should have mentioned it at least. Good call.