Notification Center

Many applications use toasts to give feedback to the user about an action. I’ve often used React Toastify to notify the user that she could be signed in successfully or that the website performed an automatic update in the background.

React Toastify has many exciting features:

  • You render the <ToastContainer /> component at the top level of your application, and later, you can call the toast() function wherever and expect a toast to appear.
  • Toasts can have a timer or be displayed for an indeterminate time.
  • When the user unfocuses the page, all toasts running a timer are frozen, and the timers restart when the user focuses the page again.
  • There can be multiple toasts, and notifications are stacked from the most recent to the oldest.
  • The configuration of each toast is independent. One toast can have a timeout when another doesn’t.

This example is a simplified re-implementation of React Toastify with XState.

Example

To play with the demo, hover over the notifications, focus another window and switch to another tab.

Trigger a notification

Notification method

This example is based on the Notification machine. Two state machines and one callback logic are involved.

The root machine is notificationCenterMachine. This state machine doesn’t define any child state; there is only the root state.

Spawning notification actors

The notificationCenterMachine acts as a proxy, knowing what notification actors are available and forwarding events to them. Its context contains a notificationRefs array.

When the state machine receives the notification.trigger event, it executes the Assign notification configuration into context action, which spawns a new notification actor and prepends the reference to the notificationRefs array. The notificationMachine is spawned with its input, containing the timeout, title, and description properties, defining how the notification machine will behave.

The notificationMachine sets its initial context as the input received. In this specific case, the types of the context and the input are equal, and we can do as follows:

context: ({ input }) => input,

Controlling the CSS animation

The notifications with a timeout display a progress bar, which is stopped when the user hovers over the notification or unfocuses the page.

A user hovers over the notification and it automatically
stops

Inspired by React Toastify, the timer of the notification is managed by the browser: the end of the animation is waited for with the animationend event, and the animation is paused with the animation-play-state CSS property.

When the animation of the progress bar ends, the React component sends an animation.end event to the notification’s actor. The state machine then goes to the Done state and sends a notification.closed event to its parent, as discussed below.

onAnimationEnd={() => {
notificationRef.send({
type: "animation.end",
});
}}

The animation runs when the notificationMachine is in Waiting for timeout.Active state:

style={{
animationDuration: state.context.timeout! + "ms",
animationPlayState:
state.matches({ "Waiting for timeout": "Active" }) === true
? "running"
: "paused",
}}

Relying on the animation’s state is brilliant because the browser serves as the single source of truth. One fun side effect of doing so is that if you pause the animation in the dev tools (Firefox can do that), the notification won’t close unexpectedly.

Instinctively, I would have opted for a full-JS implementation, probably using setTimeout and Date.now(), to control the position where the animation should restart after a pause. CSS deserves much more love!

Listening to window focus and blur

We need to call window.addEventListener() to know when the page is focused or blurred. I don’t want to listen to these events in each notification actor; these events are not tied to any specific notification. It would be better to have a single listener for each of them.

It’s perfect because we already have a state machine singleton wrapping every notification! The notificationCenterMachine invokes the windowFocusLogic actor, which sets up the listeners for the focus and blur events.

const windowFocusLogic = fromCallback(({ sendBack }) => {
window.addEventListener("focus", () => {
sendBack({
type: "window.focus",
});
});
window.addEventListener("blur", () => {
sendBack({
type: "window.blur",
});
});
});
const notificationCenterMachine = createMachine({
invoke: {
src: windowFocusLogic,
},
});

When windowFocusLogic calls the sendBack function with an event, the notificationCenterMachine receives it as if a React component sent it from outside the machine. It then forwards the event to every notification actor:

const notificationCenterMachine = createMachine({
invoke: {
src: windowFocusLogic,
},
"window.*": {
actions: enqueueActions(({ enqueue, context, event }) => {
for (const ref of context.notificationRefs) {
enqueue.sendTo(ref, event);
}
}),
},
});

The enqueueActions action programmatically defines which actions XState should run. Usually, it’s better to rely on the basic actions that make it easier to introspect the machine to determine what it does. But, sometimes, running a JavaScript function is necessary to determine what actions XState must execute.

Note that enqueueActions enqueues actions; it doesn’t execute them right away. Actions are always declarative and pure with XState. It’s crucial for APIs like state.can() to work.

We must use enqueueActions because we don’t know the number of notification actors in advance; we must compute some JavaScript to determine it.

Cleaning stopped actors

When the notification is closed because the user clicked the close button or reached the timeout, the notificationMachine goes to the Done state. In this state, the machine sends a notification.closed event to its parent:

Done: {
entry: sendParent(({ context }) => ({
type: "notification.closed",
notificationId: context.notificationId,
})),
}

To stop a spawned actor stored in the context of a machine, you need to do two things:

  • Stop the actor with the stopChild action.
  • Remove the reference to the actor from the context with the assign action.

When the notificationCenterMachine receives a notification.closed event, it executes two actions:

  1. Stop closed notification
  2. Remove closed notification from context

Each notification actor gets an ID when spawned; this is how the notificationCenterMachine knows which actor to stop.

Integrate the Notification Center with an Actor System

A Notification Center is typically a unique actor. There is a single Notification Center in an application, and every time you want to trigger a notification, you need to reach out to it.

I usually create an appMachine managing the authentication state and make it globally available throughout the code base. We can invoke the notificationCenterMachine at the root state of the appMachine to make it live in every state.

With the Systems feature of XState 5, we can even make the Notification Center available to the whole hierarchy of actors invoked and spawned by or under the appMachine:

const childMachine = createMachine({
entry: sendTo(({ system }) => system.get("notification-center"), {
type: "notification.trigger",
title: "Child machine has been loaded",
description: "Start sending it some events",
timeout,
}),
});
const appMachine = createMachine({
invoke: {
src: notificationCenterMachine,
systemId: "notification-center",
},
initial: "Checking if user is initially authenticated",
states: {
"Checking if user is initially authenticated": {
/** */
},
Authenticated: {
invoke: {
src: childMachine,
},
},
"Not authenticated": {
/** */
},
},
});

This example doesn’t implement that, but this pattern may suit real-world applications well.

Code

View in GitHub
machine.ts
import {
assertEvent,
assign,
setup,
type ActorRefFrom,
sendParent,
stopChild,
fromCallback,
enqueueActions,
} from "xstate";
const windowFocusLogic = fromCallback(
({
sendBack,
}: {
sendBack: (event: { type: "window.focus" | "window.blur" }) => void;
}) => {
window.addEventListener("focus", () => {
sendBack({
type: "window.focus",
});
});
window.addEventListener("blur", () => {
sendBack({
type: "window.blur",
});
});
}
);
export const notificationMachine = setup({
types: {
input: {} as {
notificationId: string;
timeout: number | undefined;
title: string;
description: string;
},
context: {} as {
notificationId: string;
timeout: number | undefined;
title: string;
description: string;
},
events: {} as
| { type: "close" }
| { type: "mouse.enter" }
| { type: "mouse.leave" }
| { type: "animation.end" }
| { type: "window.focus" }
| { type: "window.blur" },
},
guards: {
"Is timer defined": ({ context }) => typeof context.timeout === "number",
},
}).createMachine({
id: "Notification",
context: ({ input }) => input,
initial: "Checking if timer is required",
states: {
"Checking if timer is required": {
always: [
{
guard: "Is timer defined",
target: "Waiting for timeout",
},
{
target: "Waiting for manual action",
},
],
},
"Waiting for timeout": {
initial: "Active",
states: {
Active: {
on: {
"mouse.enter": {
target: "Hovering",
},
},
},
Hovering: {
on: {
"mouse.leave": {
target: "Active",
},
},
},
"Window inactive": {
on: {
"window.focus": {
target: "Active",
},
},
},
},
on: {
"window.blur": {
target: ".Window inactive",
},
close: {
target: "Done",
},
"animation.end": {
target: "Done",
},
},
},
"Waiting for manual action": {
on: {
close: {
target: "Done",
},
},
},
Done: {
entry: sendParent(({ context }) => ({
type: "notification.closed",
notificationId: context.notificationId,
})),
},
},
});
export const notificationCenterMachine = setup({
types: {
context: {} as {
notificationRefs: Array<ActorRefFrom<typeof notificationMachine>>;
},
events: {} as
| {
type: "notification.trigger";
timeout?: number;
title: string;
description: string;
}
| { type: "notification.closed"; notificationId: string }
| { type: "window.focus" }
| { type: "window.blur" },
},
actions: {
"Assign notification configuration into context": assign({
notificationRefs: ({ context, event, spawn }) => {
assertEvent(event, "notification.trigger");
const newNotificationId = generateId();
return [
spawn("notificationMachine", {
id: newNotificationId,
input: {
notificationId: newNotificationId,
title: event.title,
description: event.description,
timeout: event.timeout,
},
}),
...context.notificationRefs,
];
},
}),
"Stop closed notification": stopChild(({ context, event }) => {
assertEvent(event, "notification.closed");
return context.notificationRefs.find(
(ref) => ref.id === event.notificationId
)!;
}),
"Remove closed notification from context": assign({
notificationRefs: ({ context, event }) => {
assertEvent(event, "notification.closed");
return context.notificationRefs.filter(
(ref) => ref.id !== event.notificationId
);
},
}),
},
actors: {
windowFocusLogic,
notificationMachine,
},
}).createMachine({
id: "Notification Center",
context: {
notificationRefs: [],
},
invoke: {
src: "windowFocusLogic",
},
on: {
"notification.trigger": {
actions: "Assign notification configuration into context",
},
"notification.closed": {
actions: [
"Stop closed notification",
"Remove closed notification from context",
],
},
"window.*": {
actions: enqueueActions(({ enqueue, context, event }) => {
for (const ref of context.notificationRefs) {
enqueue.sendTo(ref, event);
}
}),
},
},
});
function generateId() {
return String(Math.random());
}

Get news from XState by Example

Sign up for the newsletter to be notified when more machines or an interactive tutorial are released. I respect your privacy and will only send emails once in a while.