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.

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

View in GitHub
machine.ts
/**
* 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",
},
},
},
},
});

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.