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 timeresumeTimer()— Continues from where it was pausedrestartTimer()— Resets to zero and starts overstopTimer()— Stops and triggers the onFinish callbacksetEnd()— 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.
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
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:
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
}
);