User Activity
VueUse is a gold mine for Vue.js developers. It’s a collection of composables (aka hooks in the Vue world).
VueUse brings complex utility functions, like the useIdle
composable that tracks the user’s activity on the page. The composable listens to every mousemove
, mousedown
, resize
, keydown
, touchart
, and wheel
event and considers the user active for a specific duration after receiving one of these events.
The useIdle
composable restarts the timer whenever the user interacts with the page.
![User simulates inactivity to see how the state machine reacts.](/_astro/demo._jeFEb-2_ZfEMxm.webp)
XState excels at orchestrating code with timers, and this example will be fun to reimplement! It combines the debouncing and throttling patterns.
Example
You can interact with the page and see the timer remain at 0s, or stop interacting with the page in any way,
and see it increase and the state Idle
value becoming true
:
The timeout has been reduced to five seconds for the demo. In the real-world you would be use a bigger one, like one minute.
Idle: false
Inactive: 0s
Listening to DOM events
The domEventListener
logic is a callback that sets up many event listeners on the window and optionally on the document.
A callback logic can return a callback function that’s called when an actor that was invoked or spawned based on the logic stops.
Every time the window
or the document
receives one of the listened events, the logic sends an "activity"
event to its parent actor.
const domEventListener = fromCallback(({ input, sendBack }) => { /** * Set up event listeners. */ window.addEventListener("mousemove", () => { sendBack({ type: "activity", }); });
document.addEventListener("visibilitychange", () => { sendBack({ type: "activity", }); });
return () => { /** * Clear event listeners. */ };});
The domEventListener
logic is invoked at the root state of the machine and renamed to Listen to DOM events
:
export const userActivityMachine = setup({ // ... actors: { "Listen to DOM events": domEventListener, }, // ...}).createMachine({ // ... invoke: { src: "Listen to DOM events", input: ({ context }) => ({ events: context.events, listenForVisibilityChange: context.listenForVisibilityChange, }), }, // ...});
As we’ll see below, the state machine handles the "activity"
events differently based on its current state.
Use timers to detect the user’s inactivity
The user is, by default, considered active. The initial state is Active.Idle
,
and a timer is instantly started with the value of the dynamic delay "Inactivity timeout"
.
When the timer ends, it targets the Active.Done
final state, which triggers a transition to the Inactive
state.
createMachine({ // ... initial: "Active", states: { Active: { initial: "Idle", states: { Idle: { after: { "Inactivity timeout": { target: "Done", }, }, }, Deduplicating: { /** */ }, Done: { type: "final", }, }, onDone: { target: "Inactive", }, }, Inactive: { /** */ }, },});
I like to rely on final states to emphasize where a flow ends. I dislike using global state IDs as targets and prefer using final states.
When the state machine receives the "activity"
event in the Active.Idle
state, it targets the Active.Deduplicating
state:
createMachine({ // ... initial: "Active", states: { Active: { initial: "Idle", states: { Idle: { after: { "Inactivity timeout": { target: "Done", }, }, on: { activity: { target: "Deduplicating", actions: "Assign last active timestamp to context", }, }, }, Deduplicating: { /** */ }, Done: { type: "final", }, }, onDone: { target: "Inactive", }, }, Inactive: { /** */ }, },});
The Active.Deduplicating
state throttles the "activity"
event.
Following the throttling pattern, the state machine stops listening to the event for some time.
It starts a timer of 50ms and then transitions back to the Active.Idle
state.
createMachine({ // ... initial: "Active", states: { Active: { initial: "Idle", states: { Idle: { after: { "Inactivity timeout": { target: "Done", }, }, on: { activity: { target: "Deduplicating", actions: "Assign last active timestamp to context", }, }, }, Deduplicating: { after: { 50: { target: "Idle", }, }, }, Done: { type: "final", }, }, onDone: { target: "Inactive", }, }, Inactive: { /** */ }, },});
The useIdle
composable throttles events by 50ms.
I assume the reason is that it’s cheaper to create a 50ms timer than creating many more timers that would be created if it wasn’t throttling and the state machine was receiving a lot of "activity"
events.
Finally, the Inactive
state also listens to the "activity"
event and transitions to the Active
state:
createMachine({ // ... initial: "Active", states: { Active: { initial: "Idle", states: { /** */ }, onDone: { target: "Inactive", }, }, Inactive: { on: { activity: { target: "Active", actions: "Assign last active timestamp to context", }, }, }, },});
The code for this example is short, but the logic represented is not simple. XState makes managing timers so easy that it hides the underlying complexity and instead makes the intrinsic logic stand out.
Code
/** * This state machine is a re-implementation of the useIdle hook from VueUse. * See https://github.com/vueuse/vueuse/blob/main/packages/core/useIdle/index.ts. */
import { assign, fromCallback, setup } from "xstate";
type WindowEventName = keyof WindowEventMap;
const defaultEvents: WindowEventName[] = [ "mousemove", "mousedown", "resize", "keydown", "touchstart", "wheel",];
function timestamp() { return Date.now();}
const domEventListener = fromCallback< any, { listenForVisibilityChange: boolean; events: WindowEventName[] }>(({ input, sendBack }) => { const windowEventMap = new Map<WindowEventName, () => void>(); let documentVisibilitychangeHandler: (() => void) | undefined = undefined;
for (const event of input.events) { function callback() { sendBack({ type: "activity", }); }
windowEventMap.set(event, callback);
window.addEventListener(event, callback, { passive: true }); }
if (input.listenForVisibilityChange === true) { documentVisibilitychangeHandler = () => { if (document.hidden === true) { return; }
sendBack({ type: "activity", }); };
document.addEventListener( "visibilitychange", documentVisibilitychangeHandler ); }
/** * That callback will be called when the service exits, that is, when the state that invoked it exits or * the overall state machine stops. */ return () => { for (const [event, handler] of windowEventMap.entries()) { window.removeEventListener(event, handler); }
if (documentVisibilitychangeHandler !== undefined) { document.removeEventListener( "visibilitychange", documentVisibilitychangeHandler ); } };});
export const userActivityMachine = setup({ types: { events: {} as { type: "activity" }, context: {} as { timeout: number; lastActive: number; listenForVisibilityChange: boolean; events: WindowEventName[]; }, input: {} as { /** * How long the user can stop interacting with the page before being considered inactive. * * @default 60_000 (1 minute) */ timeout?: number; /** * @default true */ listenForVisibilityChange?: boolean; /** * @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'] */ events?: WindowEventName[]; }, }, actors: { "Listen to DOM events": domEventListener, }, delays: { /** * This is a dynamic timer. The `timeout` comes from the input and isn't expect to change. */ "Inactivity timeout": ({ context }) => context.timeout, }, actions: { "Assign last active timestamp to context": assign({ lastActive: () => Date.now(), }), },}).createMachine({ id: "User activity", context: ({ input }) => ({ timeout: input.timeout ?? 60_000, lastActive: timestamp(), listenForVisibilityChange: input.listenForVisibilityChange ?? true, events: input.events ?? defaultEvents, }), invoke: { src: "Listen to DOM events", input: ({ context }) => ({ events: context.events, listenForVisibilityChange: context.listenForVisibilityChange, }), }, initial: "Active", states: { Active: { initial: "Idle", states: { Idle: { after: { "Inactivity timeout": { target: "Done", }, }, on: { activity: { target: "Deduplicating", actions: "Assign last active timestamp to context", }, }, }, Deduplicating: { description: ` We throttle here to keep things under control. Deduplicating with a small timer prevents restarting the "Inactivity timeout" too often if the state machine receives a lot of "activity" events in a short amount of time. The useIdle composable prefers to create one timer per 50ms then even more if a large amount of *activity* events are sent to the machine. `, after: { 50: { target: "Idle", }, }, }, Done: { type: "final", description: ` Use a *final* state to trigger a transition to the Inactive state. I prefer to use it instead of directly targetting the Inactive state from the Active.Idle state, because I would need to rely on a global id selector. `, }, }, onDone: { target: "Inactive", }, }, Inactive: { on: { activity: { target: "Active", actions: "Assign last active timestamp to context", }, }, }, },});
/** * This demo is greatly inspired by VueUse's demo for the useIdle hook. * See https://github.com/vueuse/vueuse/blob/main/packages/core/useIdle/demo.vue. */
import { css } from "../../../styled-system/css";import { useActor } from "@xstate/react";import { userActivityMachine } from "./machine";import type { ActorOptions, AnyActorLogic } from "xstate";import { differenceInSeconds } from "date-fns";import { useEffect, useState } from "react";import { vstack } from "../../../styled-system/patterns";
interface Props { actorOptions: ActorOptions<AnyActorLogic> | undefined;}
export function Demo({ actorOptions }: Props) { const [snapshot] = useActor(userActivityMachine, { ...actorOptions, input: { timeout: 5_000, // 5 seconds; for demo purpose }, });
const isUserIdle = snapshot.matches("Inactive") === true; const now = useTimestamp();
return ( <div className={vstack({ gap: 2, px: "6", alignItems: "stretch", lineHeight: "normal", })} > <p className={css({ color: "gray.600", fontSize: "md", mb: "2" })}> The timeout has been reduced to <b>five seconds</b> for the demo. In the real-world you would be use a bigger one, like one minute. </p>
<p> Idle:{" "} <span className={css({ color: isUserIdle === true ? "green.600" : "red.600", fontWeight: "medium", })} > {String(isUserIdle)} </span> </p>
<p> Inactive:{" "} <span className={css({ fontWeight: "medium", color: "orange.500", fontVariantNumeric: "tabular-nums", })} > {differenceInSeconds(now, snapshot.context.lastActive)}s </span> </p> </div> );}
function useTimestamp() { const [now, setNow] = useState(() => Date.now());
useEffect(() => { const timerId = setInterval(() => { setNow(Date.now()); }, 1_000);
return () => { clearInterval(timerId); }; }, []);
return now;}
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.