Stopwatch with React State and Lifecycle Hooks
Interactive Stopwatch Example
Section titled “Interactive Stopwatch Example”About This Example
Section titled “About This Example”This version demonstrates using onLifecycle
hooks instead of useEffect
:
- Uses lifecycle events to manage side effects
- Provides better TypeScript autocompletion support
- Constrains event types to their specific states
While slightly more verbose than other approaches, onLifecycle
offers a superior developer experience with:
- Strong type inference for state and event-specific data
- Automatic documentation through IDE autocomplete
- Clear separation of lifecycle events by state
For example, in a configuration like { Rejected: { enter: ev => { ev.data.error } } }
, TypeScript would correctly infer and autocomplete ev.error
since it’s part of the Rejected
state’s data
.
Machine Code
Section titled “Machine Code”import { defineStates, createMachine, assignEventApi } from "matchina";
export const createStopwatch = () => { // Define states using defineStates const states = defineStates({ Stopped: {}, Ticking: {}, Suspended: {}, });
// Create the base machine with states, transitions, and initial state const baseMachine = createMachine( states, { Stopped: { start: "Ticking", }, Ticking: { stop: "Stopped", suspend: "Suspended", clear: "Ticking", }, Suspended: { stop: "Stopped", resume: "Ticking", clear: "Suspended", }, }, "Stopped" );
//Use assignEventApi to enhance the machine with utility methods return assignEventApi(baseMachine);};
import { onLifecycle, when } from "matchina";import { useMachine } from "matchina/react";import { useState, useMemo, useEffect } from "react";import { tickEffect } from "../lib/tick-effect";import { createStopwatch } from "./machine";
export function useStopwatch() { const [startTime, setStartTime] = useState<number | undefined>(undefined); const [elapsed, setElapsed] = useState(0); // Define the state machine const stopwatch = useMemo( () => Object.assign(createStopwatch(), { startTime, elapsed, }), [] ); useEffect( () => onLifecycle(stopwatch, { "*": { enter: when( (ev) => ev.to.is("Ticking"), () => tickEffect(() => { setElapsed(Date.now() - (stopwatch.startTime ?? 0)); }) ), on: { start: { effect: () => { setStartTime(Date.now()); }, }, clear: { effect: () => setElapsed(0) }, stop: { effect: () => setElapsed(0) }, resume: { effect: () => setStartTime(Date.now() - (stopwatch.elapsed ?? 0)), }, }, }, Ticking: { on: { clear: { effect() { setStartTime(Date.now()); }, }, }, }, Suspended: { on: { clear: { effect() { setStartTime(undefined); }, }, }, }, }), [stopwatch] ); useMachine(stopwatch); stopwatch.startTime = startTime; stopwatch.elapsed = elapsed; return stopwatch;}
import { getAvailableActions as getStateEvents } from "matchina";import type { Stopwatch } from "../stopwatch-common/types";import { useMachine } from "matchina/react";
/** * A simple Stopwatch UI component that displays the current state, elapsed time, * and available actions based on the current state. */export function StopwatchView({ machine }: { machine: Stopwatch }) { useMachine(machine); const state = machine.getState();
// Generate color class based on state const stateColorClass = state.match({ Stopped: () => "text-red-500", Ticking: () => "text-green-500", Suspended: () => "text-yellow-500", });
return ( <div className="p-4 rounded border"> {/* State display */} <div className={`inline ${stateColorClass}`}>{state.key}</div>
{/* Elapsed time */} <div className="text-4xl font-bold my-4"> {(machine.elapsed / 1000).toFixed(1)}s </div>
{/* Action buttons */} <div className="flex flex-wrap items-center gap-2"> {getStateEvents(machine.transitions, state.key) .filter((event) => !event.startsWith("_")) .map((event) => ( <button className="px-3 py-1 rounded bg-blue-500 text-white text-sm" key={event} onClick={() => { (machine as any)[event](); }} > {event} </button> ))} </div> </div> );}
import { StopwatchView } from "./StopwatchView";import { useStopwatch } from "./useStopwatch";
export function Stopwatch() { const stopwatch = useStopwatch(); return <StopwatchView machine={stopwatch as any} />;}