Building a React Native Timer Hook — Digitteck
Building a React Native Timer Hook
frontend·14 October 2025·4 min read

Building a React Native Timer Hook

Description

useTimer is a React Native hook that provides a configurable interval-based timer with full lifecycle control. It counts upward in customizable steps (e.g., every second) until an optional end duration is reached, triggering a callback when complete.

Background/Foreground Recovery — The hook monitors app state changes using React Native's AppState listener. When the app returns to the foreground after being backgrounded, it calculates the elapsed time difference and updates the timer accordingly — ensuring the displayed time remains accurate even when the app wasn't actively processing updates.

Controls — The hook exposes complete timer management:

  • pauseTimer() — Pauses at the current time
  • resumeTimer() — Continues from where it was paused
  • restartTimer() — Resets to zero and starts over
  • stopTimer() — Stops and triggers the onFinish callback
  • setEnd() — Dynamically updates the end duration (when stopped)

Reference Management — Timer interval references are notoriously tricky in React: improper cleanup leads to memory leaks or multiple intervals running simultaneously. This hook manages all setInterval references using refs and ensures proper cleanup in useEffect return functions, guaranteeing no lingering intervals or memory leaks regardless of how the timer is controlled or when the component unmounts.

typescript
import {Duration, TimeDuration} from "typed-duration";
import {useEffect, useRef, useState} from "react";
import {millisecondsFrom, millisecondsOf, secondsOf} from "typed-duration/dist/lib";
import {TimerState} from "@/src/effects/types";
import {AppState} from 'react-native';
import {Logger} from "@/src/services/logging";

export function useTimer(
    step: TimeDuration,
    end?: TimeDuration,
    onFinish?: (reached: TimeDuration) => void,
    startImmediately: boolean = true) {

    const effectiveEnd = useRef<TimeDuration | undefined>(end);

    const timerState = useRef<TimerState>({
        current: secondsOf(0),
        end: effectiveEnd.current,
        runningState: startImmediately ? 'running' : 'stopped',
    });
    const [timerStateOutput, setTimerStateOutput] = useState(timerState.current);

    const finished = useRef(startImmediately === false);
    const intervalRef = useRef<number | undefined>(undefined);
    const lastTick = useRef<number | undefined>(undefined);

    useEffect(() => {
        const sub = AppState.addEventListener('change', (next) => {
            if (next === 'active') {
                if (lastTick.current && timerState.current.runningState === 'running') {
                    let currentTimestampMS = new Date().getTime();
                    let differenceMS = currentTimestampMS - lastTick.current;
                    if (differenceMS > millisecondsFrom(step)) {
                        setCurrentState(t => ({
                            ...t,
                            current: millisecondsOf(millisecondsFrom(t.current) + differenceMS),
                        }));
                    }
                }
            }
        });
        return () => { sub.remove(); };
    }, []);

    const setCurrentState = (stateUpdate: TimerState | { (state: TimerState): TimerState }) => {
        lastTick.current = new Date().getTime();
        if (typeof stateUpdate === 'function') {
            timerState.current = stateUpdate(timerState.current);
        } else {
            timerState.current = stateUpdate;
        }
        setTimerStateOutput({
            current: timerState.current.current,
            runningState: timerState.current.runningState,
            end: timerState.current.end,
        });
    };

    const shouldFinish = () =>
        !finished.current
        && effectiveEnd.current
        && Duration.seconds.from(timerState.current.current) >= Duration.seconds.from(effectiveEnd.current);

    const setFinishedState = (state: TimerState) => {
        setCurrentState({ ...state, runningState: 'stopped' });
        intervalRef.current = null;
        finished.current = true;
        lastTick.current = undefined;
        onFinish(timerState.current.current);
    };

    const handlePause = () => {
        clearInterval(intervalRef.current);
        lastTick.current = undefined;
        intervalRef.current = null;
        if (shouldFinish()) {
            setFinishedState({ end: effectiveEnd.current, current: effectiveEnd.current, runningState: 'stopped' });
        } else {
            setCurrentState(state => { state.runningState = 'paused'; return state; });
        }
    };

    const handleResume = () => { if (!intervalRef.current) startOrResume(); };

    const handleStop = () => {
        clearInterval(intervalRef.current);
        intervalRef.current = null;
        finished.current = true;
        setFinishedState(timerState.current);
    };

    const handleRestart = () => {
        finished.current = false;
        clearInterval(intervalRef.current);
        intervalRef.current = null;
        setCurrentState({ current: secondsOf(0), end: effectiveEnd.current, runningState: 'running' });
        startOrResume();
    };

    const handleSetEnd = (end: TimeDuration) => {
        if (intervalRef.current === null || intervalRef.current === undefined) {
            effectiveEnd.current = end;
        }
    };

    const startOrResume = () => {
        if (effectiveEnd.current
            && Duration.seconds.from(timerState.current.current) >= Duration.seconds.from(effectiveEnd.current)) {
            clearInterval(intervalRef.current);
            intervalRef.current = null;
            return;
        }
        if (finished.current) {
            clearInterval(intervalRef.current);
            intervalRef.current = null;
            return;
        }
        intervalRef.current = setInterval(() => {
            if (effectiveEnd.current
                && Duration.seconds.from(timerState.current.current) >= Duration.seconds.from(effectiveEnd.current)) {
                clearInterval(intervalRef.current);
                intervalRef.current = null;
                setFinishedState({ end: effectiveEnd.current, current: effectiveEnd.current, runningState: 'stopped' });
            } else {
                setCurrentState({
                    end: effectiveEnd.current,
                    current: secondsOf(Duration.seconds.from(timerState.current.current) + Duration.seconds.from(step)),
                    runningState: 'running',
                });
            }
        }, Duration.milliseconds.from(step));
    };

    useEffect(() => {
        if (startImmediately) startOrResume();
        return () => { clearInterval(intervalRef.current); intervalRef.current = null; };
    }, [startImmediately]);

    return {
        state: timerStateOutput,
        pauseTimer: () => handlePause(),
        resumeTimer: () => handleResume(),
        restartTimer: () => handleRestart(),
        stopTimer: () => handleStop(),
        setEnd: (end: TimeDuration) => handleSetEnd(end),
    };
}

Types

typescript
export type TimerStateRunningState = 'running' | 'paused' | 'stopped';

export type TimerState = {
    end?: TimeDuration;
    current: TimeDuration;
    runningState: TimerStateRunningState;
}

Usage

Usage is straightforward — call the hook in your component with a step duration and an optional end duration:

typescript
const { state, pauseTimer, resumeTimer, restartTimer, stopTimer } = useTimer(
    secondsOf(0.5),   // step — tick every 0.5s
    end,              // optional total duration
    (duration) => {
        // called when end is reached
    }
);

Tags

TypeScriptReact NativeJavaScriptFrontend
digitteck

© 2026 Digitteck