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 including:
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 can lead 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,
// gets triggered when there is an end (obviously)
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',
});
// a state returned for responseviness
const [timerStateOutput, setTimerStateOutput] = useState(timerState.current);
const finished = useRef(startImmediately === false);
const intervalRef = useRef<number | undefined>(undefined);
const lastTick = useRef<number | undefined>(undefined);
useEffect(() => {
// logic for re-calculating the timer when it the app
// is active again.
// when the app is in background no processing occurs.
const sub = AppState.addEventListener('change', (next) => {
if (next === 'active') {
// recalculate remaining time
if (lastTick.current && timerState.current.runningState === 'running') {
let currentTimestampMS = new Date().getTime();
let differenceMS = currentTimestampMS - lastTick.current;
// if the app is stalled by more than 2x the step
if (differenceMS > millisecondsFrom(step)) {
setCurrentState(t => {
return {
...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;
}
// Setting the entire object is allowing for the state changes to be correctly detected
// by components using the timer.
// case: when updating just the running state and setting the output, this change is not always detected
setTimerStateOutput({
current: timerState.current.current,
runningState: timerState.current.runningState,
end: timerState.current.end,
});
}
const handlePause = () => {
clearInterval(intervalRef.current);
lastTick.current = undefined;
intervalRef.current = null;
// edge case when user clicks stop on the last second
// the interval will be cleared once the step has been looped
// and at this point the set finished won't be reached
if (shouldFinish()) {
setFinishedState({
end: effectiveEnd.current,
current: effectiveEnd.current,
runningState: "stopped",
})
} else {
setCurrentState(state => {
state.runningState = 'paused';
return state;
});
}
}
const shouldFinish = () => {
return !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 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) => {
// must be stopped when setting the interval
if (intervalRef.current === null || intervalRef.current === undefined) {
effectiveEnd.current = end;
}
}
// keep start method out of useEffect or useCallback
// to avoid having wierd behavior or re-renderings
// resume will create a new interval and will add to the current state
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(() => {
// case where resuming reached immediately the end.
// happens when user paused on the last second
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),
};
}
export type TimerStateRunningState = 'running' | 'paused' | 'stopped';
export type TimerState = {
end?: TimeDuration;
current: TimeDuration;
runningState: TimerStateRunningState
}
Usage is straight forward, just call the hook in your component
const {state, pauseTimer, resumeTimer, restartTimer, stopTimer} = useTimer(
secondsOf(0.5), // step
end, // optional total duration
(duration) => {
// logic when end reached
});








