Skip to content
All components

Free reference

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.