Skip to content

Stopwatch

A proper stopwatch that doesn't drift over time.

This example also demonstrates how state and logic can be separated from it's representation if needed.

import { Expression, IndexFor, Show, get, memo, sig, teardown } from "rvx";

export function Example() {
    // Create a reactive timer instance:
    const timer = createTimer();

    // Render the timer UI:
    return <div class="column">
        <div style={{ "font-size": "2rem" }}>
            <Time value={timer.elapsed} />
        </div>
        <div class="row">
            <Show when={timer.running} else={() => <>
                    <button on:click={timer.start}>Start</button>
                    <button on:click={timer.reset}>Reset</button>
            </>}>
                {() => <>
                    <button on:click={timer.stop}>Stop</button>
                    <button on:click={timer.lap}>Lap</button>
                </>}
            </Show>
        </div>
        <ul>
            <IndexFor each={timer.laps}>
                {(lap, index) => <li>
                    Lap {index + 1}: <Time value={lap.lap} />
                </li>}
            </IndexFor>
        </ul>
    </div>;
}

// A small component for displaying a time in milliseconds:
function Time(props: {
    value: Expression<number>;
}) {
    return <>
        <Show when={() => {
            const hours = Math.floor(get(props.value) / 1000 / 60 / 60);
            if (hours > 0) {
                return String(hours);
            }
        }}>
            {hours => <>{hours}:</>}
        </Show>
        {() => String(Math.floor(get(props.value) / 1000 / 60) % 60)}
        :
        {() => String(Math.floor(get(props.value) / 1000) % 60).padStart(2, "0")}
        .
        {() => String(Math.floor(get(props.value)) % 1000).padStart(3, "0")}
    </>;
}

function createTimer() {
    type State = {
        type: "paused",
        elapsed: number,
        lastLap: number,
    } | {
        type: "running",
        started: number,
        lastLap: number,
    };

    // Create a signal that is updated with the current time every frame:
    const now = sig(performance.now());
    let nextFrame = requestAnimationFrame(function update() {
        nextFrame = requestAnimationFrame(update);
        now.value = performance.now();
    });
    // Stop updating the timer when this lifecycle is disposed:
    teardown(() => cancelAnimationFrame(nextFrame));

    const laps = sig<Lap[]>([]);
    const state = sig<State>({
        type: "paused",
        elapsed: 0,
        lastLap: 0,
    });

    // Compute the elapsed time on demand from
    // the current time and state signals:
    const elapsed = memo(() => {
        switch (state.value.type) {
            case "paused": return state.value.elapsed;
            case "running": return now.value - state.value.started;
        }
    });

    // Return an object with reactive accessors for the
    // current state and some functions to control this timer:
    return {
        running: () => state.value.type === "running",
        laps: () => laps.value,
        elapsed,

        start: () => {
            if (state.value.type === "paused") {
                state.value = {
                    type: "running",
                    started: now.value - state.value.elapsed,
                    lastLap: state.value.lastLap,
                };
            }
        },

        stop: () => {
            if (state.value.type === "running") {
                state.value = {
                    type: "paused",
                    elapsed: elapsed(),
                    lastLap: state.value.lastLap,
                };
            }
        },

        lap: () => {
            const lap = elapsed() - state.value.lastLap;
            if (lap > 0) {
                state.value.lastLap = elapsed();
                laps.update(laps => {
                    laps.push({
                        lap,
                        elapsed: elapsed(),
                    });
                });
            }
        },

        reset: () => {
            laps.value = [];
            state.value = {
                type: "paused",
                elapsed: 0,
                lastLap: 0,
            };
        },
    };
}

interface Lap {
    lap: number;
    elapsed: number;
}