Notification
Notifications, toasts, and snackbars β however you name them β are essential UI elements suitable to be implemented with a state machine. They use a timer, and this single reason is enough to use XState for me.
This notification example allows only a single notification to be opened simultaneously. Stacking notifications will be showcased on another machine and involve spawned actors.
The Notification Center example is based on this. It adds the feature of stacked notifications and manages it with actors. You can check it out for a more realistic sample.
Example
A notification can be triggered only in the Closed
state, that is, when the notification is not opened yet.
Conversely, it can only be closed in the Open
state.
When entering the Open
state, it will first go to the Checking if timer is required
state.
This state, often called a determining state, checks if a timeout has been defined for the notification.
If a timeout was defined, it goes to Waiting for timeout
. Otherwise, it goes to Waiting for manual action
.
The machine uses an eventless transition with the always
keyword. This state is exited immediately after entering.
The close
event is listened to in both Waiting for timeout
and Waiting for manual action
states.
When the close
event is received, the machine goes to the Done
state, whose type is final
.
The Open
state defines an onDone
transition, taken when it reaches one of its child final states,
and makes the machine go back to Closed
state.
The benefit of the Done
state is explicitly saying that itβs the end of the Closed
state.
It also acts as a layer of abstraction: the child states of Open
donβt have to know about states outside their parent.
Final states are a powerful feature of state charts, especially with parallel states.
Code
import { assertEvent, assign, setup } from "xstate";
export const notificationMachine = setup({ types: { context: {} as { timeout: number | undefined; title: string | undefined; description: string | undefined; }, events: {} as | { type: "trigger"; timeout?: number; title: string; description: string; } | { type: "close" }, }, delays: { "Notification timeout": ({ context }) => { if (context.timeout === undefined) { throw new Error("Expect timeout to be defined."); }
return context.timeout; }, }, actions: { "Assign notification configuration into context": assign(({ event }) => { assertEvent(event, "trigger");
return { title: event.title, description: event.description, timeout: event.timeout, }; }), }, guards: { "Is timer defined": ({ context }) => typeof context.timeout === 'number', }}).createMachine({ id: "Notification", context: { timeout: undefined, title: undefined, description: undefined, }, initial: "Closed", states: { Closed: { on: { trigger: { target: "Open", actions: "Assign notification configuration into context", }, }, }, Open: { 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": { after: { "Notification timeout": { target: "Done", }, }, on: { close: { target: "Done", }, }, }, "Waiting for manual action": { on: { close: { target: "Done", }, }, }, Done: { type: "final", }, }, onDone: { target: "Closed", }, }, },});
import { css } from "../../../styled-system/css";import { useActor } from "@xstate/react";import { notificationMachine } from "./machine";import type { ActorOptions, AnyActorLogic } from "xstate";import { Transition } from "@headlessui/react";import { Fragment, useState } from "react";import { flex, vstack } from "../../../styled-system/patterns";import { input } from "../authentication/recipes";
interface Props { actorOptions: ActorOptions<AnyActorLogic> | undefined;}
export function Demo({ actorOptions }: Props) { const [state, send] = useActor(notificationMachine, actorOptions);
const timeoutOptions: Array<{ title: string; value: number | undefined }> = [ { title: "No timeout", value: undefined, }, { title: "5s", value: 5_000, }, { title: "10s", value: 10_000, }, ];
return ( <div className={css({ pos: "relative" })}> <div aria-live="assertive" className={css({ pointerEvents: "none", pos: "fixed", inset: "0", display: "flex", alignItems: "flex-start", p: "6", zIndex: "20", })} > <div className={css({ display: "flex", w: "full", flexDir: "column", alignItems: "flex-end", mb: "4", })} > <Transition show={state.matches("Open") === true} as={Fragment} enter={css({ transitionTimingFunction: "ease-out", transitionDuration: "slow", transitionProperty: "all", })} enterFrom={css({ translate: "auto", opacity: "0", translateY: { base: "-2", sm: "0" }, translateX: { sm: "2" }, })} enterTo={css({ translate: "auto", translateY: "0", translateX: "0", opacity: "1", })} leave={css({ transitionTimingFunction: "ease-in", transitionDuration: "fast", transitionProperty: "all", })} leaveFrom={css({ opacity: "1" })} leaveTo={css({ opacity: "0" })} > <div className={css({ pointerEvents: "auto", w: "full", maxW: "sm", overflow: "hidden", rounded: "lg", bgColor: "white", shadow: "lg", borderWidth: "1", borderStyle: "solid", borderColor: "gray.200", })} > <div className={css({ p: "4" })}> <div className={css({ display: "flex", alignItems: "flex-start" })} > <div className={css({ flexShrink: "0" })}> <svg className={css({ h: "7", w: "7", color: "green.400" })} fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true" > <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> </div> <div className={css({ ml: "3", w: "0", flex: "1", pt: "0.5" })} > <p className={css({ fontSize: "md", lineHeight: "tight", fontWeight: "medium", color: "gray.900", })} > {state.context.title} </p> <p className={css({ mt: "1", fontSize: "md", lineHeight: "tight", color: "gray.500", })} > {state.context.description} </p> </div> <div className={css({ ml: "4", display: "flex", flexShrink: "0", })} > <button type="button" className={css({ display: "inline-flex", rounded: "md", bgColor: "white", color: "gray.400", cursor: "pointer", _hover: { color: "gray.500" }, _focus: { ring: "none", ringOffset: "none", shadow: "2", }, })} onClick={() => { send({ type: "close", }); }} > <span className={css({ srOnly: true })}> Close </span> <svg className={css({ h: "5", w: "5" })} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" > <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /> </svg> </button> </div> </div> </div> </div> </Transition> </div> </div>
<form className={vstack({ gap: "6", alignItems: "stretch", px: "4" })} onSubmit={(e) => { e.preventDefault();
const formData = new FormData(e.currentTarget); const rawTitle = formData.get("title"); const rawDescription = formData.get("description"); const rawTimeout = formData.get("timeout");
const timeout = rawTimeout === "" ? undefined : Number(rawTimeout);
send({ type: "trigger", title: String(rawTitle), description: String(rawDescription), timeout, }); }} > <h2 className={css({ fontSize: "xl", fontWeight: "semibold", color: "gray.900" })}> Trigger a notification </h2>
<div> <label htmlFor="title" className={css({ display: "block", fontSize: "sm", fontWeight: "medium", color: "gray.900", })} > Title </label>
<div className={css({ mt: "2" })}> <input id="title" name="title" type="text" required defaultValue="Successfully saved!" className={input()} /> </div> </div>
<div> <label htmlFor="description" className={css({ display: "block", fontSize: "sm", fontWeight: "medium", color: "gray.900", })} > Description </label>
<div className={css({ mt: "2" })}> <input id="description" name="description" type="text" required defaultValue="Your XState machine has been saved π" className={input()} /> </div> </div>
<div> <label className={css({ display: "block", fontSize: "sm", fontWeight: "medium", color: "gray.900", })} > Timeout </label>
<fieldset className={css({ mt: "2" })}> <legend className={css({ srOnly: true })}> Notification method </legend> <div className={vstack({ gap: "2", alignItems: "stretch" })}> {timeoutOptions.map(({ title, value }) => ( <div key={title} className={css({ display: "flex", alignItems: "center" })} > <input id={title} name="timeout" type="radio" defaultChecked={value === undefined} value={value} className={css({ h: "4", w: "4", borderColor: "gray.300", color: "gray.600", })} /> <label htmlFor={title} className={css({ ml: "3", display: "block", fontSize: "sm", fontWeight: "medium", color: "gray.900", })} > {title} </label> </div> ))} </div> </fieldset> </div>
<div className={flex({ justifyContent: "center" })}> <button type="submit" className={css({ rounded: "md", bg: "gray.800", px: "2.5", py: "1.5", fontSize: "sm", lineHeight: "sm", fontWeight: "semibold", color: "gray.50", shadow: "sm", cursor: "pointer", _hover: { bgColor: "gray.700" }, })} > Trigger </button> </div> </form> </div> );}
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.