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.
Disclaimer
For this example we will be using React Native Paper
. It greatly helps with design and saves development time. It might take you some extra steps to integrate, but it is easy and intuitive to use.
Background
After setting the basics of the app architecture, we can now continue with the introduction of other important functionalities. In this post we will have a look at how we handle any sort of in-app notifications/messages that we want to display to the user.
While working on our application, we observed that the ways of providng the user with proper visual feedback grow. To manage all our snackbars/modals/dialogs/banners, we decided to move their orchestration in a separate machine.
Notification Center
The notificationCenter
machine is spawned on project initilization. Its reference is kept at root level so that it is be available for all spawned children to communicate with.
For this example we are only handling two types of notification feedbacks but it is easily extendable.
export const notificationCenterMachine = setup({
types: {
context: {} as {
snackbar: {
type: Extract<NotificationType, "snackbar">;
message: string;
severity: NotificationSeverity;
};
modal: {
type: Extract<NotificationType, "modal">;
title: string;
message: string;
};
},
events: {} as
| {
type: "NOTIFY";
notification: Notification;
}
| { type: "OPEN_SNACKBAR" }
| { type: "OPEN_MODAL" }
| { type: "CLOSE" },
},
}).createMachine({
context: {
snackbar: {
type: "snackbar",
message: "",
severity: "error",
},
modal: { type: "modal", message: "", title: "" },
},
id: "notification",
initial: "idle",
on: {
NOTIFY: {
actions: enqueueActions(({ event, enqueue }) => {
if (event.notification.type === "snackbar") {
enqueue.assign({ snackbar: event.notification });
enqueue.raise({ type: "OPEN_SNACKBAR" });
}
if (event.notification.type === "modal") {
enqueue.assign({ modal: event.notification });
enqueue.raise({ type: "OPEN_MODAL" });
}
}),
},
OPEN_SNACKBAR: {
target: ".snackbar.open",
},
OPEN_MODAL: {
target: ".modal.open",
},
},
type: "parallel",
states: {
idle: {},
snackbar: {
initial: "closed",
states: { open: { on: { CLOSE: { target: "closed" } } }, closed: {} },
},
modal: {
initial: "closed",
states: { open: { on: { CLOSE: { target: "closed" } } }, closed: {} },
},
},
});
The machine listens for the NOTIFY
event, which based on the type of notification it receives, keeps the latest notification in the context. The notification is then read and displayed/closed by the corresponding component.
Feedback
There are two components that are subscribed to the notificationCenter
reference - NotificationSnackbar
and NotificationModal
. They expect the refNotificationCenter
as prop and based on the state decide whether to show the notification. The components are rendered outside of the so that they are available to all application screens.
export function Navigation() {
const { send, state } = useApp();
return (
<NavigationContainer
onReady={() => {
send({ type: "START_APP" });
}}
ref={navigationRef}
>
<RootNavigator />
{state.context.refNotificationCenter && (
<>
<NotificationSnackbar actor={state.context.refNotificationCenter} />
<NotificationModal actor={state.context.refNotificationCenter} />
</>
)}
</NavigationContainer>
);
}
Communication
The issue with this approach in xState v4 was that we couldn't predict how deep our actor tree would grow. Sending events between siblings and grandparents in was not straightforward. In case we needed to send an event through several levels of hierarchy, each actor should act as a middleman and resend the event to its parent until the final goal is reached.
The new actor system makes it a lot easier with the receptionist pattern
. XState creates implicitly a system, which now gives us a chance to reach out the notification center by sending single event from any child machine.
sendToNotificationCenter: sendTo(
({ system }) => {
return system.get("notificationCenter");
},
(_, params: { notification: Notification }) => {
return {
type: "NOTIFY",
notification: params.notification,
};
},
)
Unfortunately, similarly to sendParent
, we loose our type-safety. As a workaround, I'm using a simple util to guarantee that the notification is in the right format:
export function getNotificationCenterEvent(
{},
params: { notification: Notification },
) {
return {
type: "NOTIFY",
notification: params.notification,
};
}
sendToNotificationCenter: sendTo(({ system }) => {
return system.get("notificationCenter");
}, getNotificationCenterEvent)
After having the action registered with the setup
method, we can simply call it with:
{
type: "sendToNotificationCenter",
params: ({ event }) => {
return {
notification: {
type: "snackbar",
severity: "success",
message: `You've added an item with id ${event.output.item.id}.`,
},
};
},
}
You can check the end results by opening the List
screen and play around with the new functionalities.
Conclusion
Leaving all the advantages aside, I was expecting better type-safety with the sendTo
action in combination with the system.get()
method. Currently, the situation is similar to what we can achieve with sendParent
. However, the flexibility in communication provided by the receptionist pattern enhances the developer experience.
Secondly, this is the first time I've experimented with enqueueActions()
and I'm beginning to see its potential. It is different from what I've been used to, but it can greatly simplify state machines.
Next, I plan to implement a registration wizard/funnel.
Top comments (0)