Free referenceMehdi Davoodi
React Native Stopwatch
A correct React Native stopwatch never adds the interval delay to a running total, because setInterval drifts: store the timestamp the run started at and compute elapsed time from Date.now() on every tick.
Installation
Nothing to install. The stopwatch below is core React Native and Expo only.
Usage
One file. Drop it in a screen and it runs.
import { useCallback, useEffect, useRef, useState } from 'react';
import {
AppState,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
type Timing = {
/** Date.now() when the current run began. 0 while paused. */
startedAt: number;
/** Milliseconds banked by every previous run. */
banked: number;
};
function format(ms: number) {
const centis = Math.floor(ms / 10);
const pad = (n: number) => String(n).padStart(2, '0');
const minutes = Math.floor(centis / 6000);
const seconds = Math.floor(centis / 100) % 60;
return `${pad(minutes)}:${pad(seconds)}.${pad(centis % 100)}`;
}
export default function Stopwatch() {
const timing = useRef<Timing>({ startedAt: 0, banked: 0 });
const [elapsed, setElapsed] = useState(0);
const [running, setRunning] = useState(false);
// The one place elapsed time is ever derived.
const read = useCallback(() => {
const { startedAt, banked } = timing.current;
return startedAt === 0 ? banked : banked + (Date.now() - startedAt);
}, []);
// Repaint while running. Frames read the clock, they never advance it.
useEffect(() => {
if (!running) return;
let frame = 0;
const loop = () => {
setElapsed(read());
frame = requestAnimationFrame(loop);
};
frame = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frame);
}, [running, read]);
// JS timers stall in the background, so recompute the moment we are back.
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => {
if (next === 'active') setElapsed(read());
});
return () => sub.remove();
}, [read]);
const start = () => {
if (timing.current.startedAt !== 0) return;
timing.current = { ...timing.current, startedAt: Date.now() };
setRunning(true);
};
const pause = () => {
const { startedAt, banked } = timing.current;
if (startedAt === 0) return;
timing.current = { startedAt: 0, banked: banked + (Date.now() - startedAt) };
setRunning(false);
setElapsed(timing.current.banked);
};
const reset = () => {
timing.current = { startedAt: 0, banked: 0 };
setRunning(false);
setElapsed(0);
};
const resetDisabled = !running && elapsed === 0;
return (
<View style={styles.root}>
<Text
accessibilityRole="timer"
accessibilityLabel={`${Math.floor(elapsed / 1000)} seconds`}
style={styles.time}
>
{format(elapsed)}
</Text>
<View style={styles.row}>
<Pressable
accessibilityRole="button"
accessibilityLabel={running ? 'Pause the stopwatch' : 'Start the stopwatch'}
onPress={running ? pause : start}
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
>
<Text style={styles.label}>{running ? 'Pause' : 'Start'}</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel="Reset the stopwatch"
disabled={resetDisabled}
onPress={reset}
style={({ pressed }) => [
styles.button,
styles.secondary,
resetDisabled && styles.disabled,
pressed && styles.pressed,
]}
>
<Text style={[styles.label, styles.labelSecondary]}>Reset</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { alignItems: 'center', gap: 28, padding: 24 },
time: {
fontSize: 56,
color: '#111111',
fontVariant: ['tabular-nums'],
fontFamily: Platform.select({
ios: 'Menlo',
android: 'monospace',
default: 'monospace',
}),
},
row: { flexDirection: 'row', gap: 12 },
button: {
minWidth: 116,
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 999,
backgroundColor: '#111111',
},
secondary: { backgroundColor: '#ECECEC' },
disabled: { opacity: 0.4 },
pressed: { opacity: 0.75 },
label: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
labelSecondary: { color: '#111111' },
});
How it works
The clock is one number: startedAt, the value of
Date.now() when the current run began. Every frame asks for
Date.now() - startedAt and adds whatever earlier runs banked.
Nothing accumulates, so the reading stays right no matter how badly the tick
behaves.
Compare the version people write first: a setInterval(tick, 10)
doing setElapsed((e) => e + 10). The delay you pass a timer is
a floor, not a promise, and the callback runs whenever the JS thread is free.
Each late tick still adds exactly 10, so the display falls behind and keeps
falling.
Pause banks and clears: the finished run is added to banked and
startedAt goes to 0, which doubles as the "not running" flag.
Resume stamps a fresh startedAt. Repainting is a
requestAnimationFrame loop, so a dropped frame costs a repaint,
never a millisecond.
Gotchas
JS timers do not fire in the background
iOS suspends the JavaScript thread seconds after the app is backgrounded and
Android throttles it, so intervals and animation frames stop. An accumulator
comes back permanently short; a timestamp does not, so recovery is one
AppState listener that recomputes on 'active'. The
caveat: Date.now() is wall clock, so a device clock change
mid-run makes the reading jump.
Proportional digits make the numbers dance
In most fonts a 1 is narrower than a 0, so a readout changing 100 times a
second visibly jitters. The code uses both fixes:
fontVariant: ['tabular-nums'] asks the font for fixed-width
figures, and a monospace fontFamily guarantees it when the font
has no tabular feature.
A render every frame is not free
Every frame calls setElapsed, so this component and its children
re-render 60 times a second, or 120 on a high refresh rate screen. Fine
alone, expensive inside a screen holding a list. Moving the readout into its
own component stops the re-render there, but the state moves with it: the
parent then needs a boolean, not elapsed, to disable Reset.
Screen readers read the whole number
Neither VoiceOver nor TalkBack narrates a value that changes on its own, but
a user who focuses the readout hears every centisecond.
accessibilityRole="timer" tells VoiceOver the value updates
often, so it polls instead of interrupting, and an
accessibilityLabel in whole seconds keeps the announcement short.
Label the buttons by what they do.
How do I keep a React Native stopwatch running in the background?
You do not keep it running, you recompute it. JavaScript timers stop firing
once iOS suspends the app, so anything that counts ticks loses the
background time. Store the timestamp the run started at, derive elapsed time
from Date.now(), and use AppState to recalculate
when the app returns to 'active'. The stopwatch was
never wrong while backgrounded, only unpainted.
Why does setInterval drift in React Native?
setInterval(fn, 10) means "no sooner than 10 milliseconds", not
"every 10 milliseconds". The callback is queued on the JavaScript thread and
runs only once that thread is free, so renders and network responses push it
late. If each callback adds a fixed 10 milliseconds to a total, every delay
becomes lost time and the stopwatch runs slow. Deriving elapsed time
from a start timestamp makes the tick rate irrelevant to accuracy.
Do I need a library for a stopwatch in React Native?
No. Start, pause, reset and centisecond digits are about 140 lines of React
and React Native, styles included, and add nothing to package.json. Timer
packages mostly wrap the same Date.now() arithmetic. Reach for
a native module only when you need a monotonic clock or a timer that
survives under a background task.
How do I add lap times to a React Native stopwatch?
Keep an array of totals in state and push the current reading into it on each lap press. Because elapsed time is derived from the start timestamp, a lap is a snapshot of that same function, and each lap's split is the difference between its total and the previous entry. Laps need no extra timers and cannot desynchronize from the display.
Every component on this page is free to copy. Motionary sells the polished, production versions over at the catalog.