Why setInterval minus one is wrong
The first version everyone writes:
// Do not ship this.
const [seconds, setSeconds] = useState(600);
useEffect(() => {
const id = setInterval(() => setSeconds((s) => s - 1), 1000);
return () => clearInterval(id);
}, []);It has two independent problems. First, setInterval(fn, 1000) does not fire every 1000ms. It fires every 1000ms plus whatever the JavaScript thread was busy with, and on a phone that is routinely 10 to 50ms per tick during animation or list scrolling. Over a ten-minute timer that is several seconds of drift, all in the same direction.
Second, when the app is backgrounded, iOS suspends the JavaScript thread within seconds and Android throttles timers hard. The interval stops firing. When the user comes back two minutes later the counter has lost two minutes and cheerfully continues from where it left off. For a cooking timer or a workout rest that is not a rounding error, it is a broken feature.
The fix: count from a timestamp
Store when the countdown ends. Every tick, ask the clock how far away that is. The interval becomes a refresh rate for the display, not the source of truth, so neither drift nor a paused thread can make the value wrong. Here is the whole hook, including pause, resume, and the app-state subscription:
// useCountdown.ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState } from 'react-native';
type Options = { onComplete?: () => void; tickMs?: number };
export function useCountdown(durationMs: number, { onComplete, tickMs = 250 }: Options = {}) {
// endsAt is the source of truth while running. null = paused/stopped.
const [endsAt, setEndsAt] = useState<number | null>(null);
// remaining is only for display.
const [remaining, setRemaining] = useState(durationMs);
const completed = useRef(false);
const recompute = useCallback(() => {
if (endsAt === null) return;
const left = Math.max(0, endsAt - Date.now());
setRemaining(left);
if (left === 0 && !completed.current) {
completed.current = true;
setEndsAt(null);
onComplete?.();
}
}, [endsAt, onComplete]);
// Tick while running.
useEffect(() => {
if (endsAt === null) return;
recompute();
const id = setInterval(recompute, tickMs);
return () => clearInterval(id);
}, [endsAt, recompute, tickMs]);
// The moment the app returns to the foreground, read the clock again.
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') recompute();
});
return () => sub.remove();
}, [recompute]);
const start = useCallback(() => {
completed.current = false;
setEndsAt(Date.now() + remaining);
}, [remaining]);
const pause = useCallback(() => {
if (endsAt === null) return;
setRemaining(Math.max(0, endsAt - Date.now()));
setEndsAt(null);
}, [endsAt]);
const reset = useCallback(() => {
completed.current = false;
setEndsAt(null);
setRemaining(durationMs);
}, [durationMs]);
return { remaining, isRunning: endsAt !== null, endsAt, start, pause, reset };
}A 250ms tick is deliberate. It costs almost nothing and means the displayed second changes within a quarter second of the real one, so two timers on screen never appear out of step. Rendering four times a second is fine as long as the component that re-renders is small, which is the next point.
Formatting without the jitter
export function formatCountdown(ms: number) {
// ceil, not floor: show 0:01 until the moment it is actually 0.
const total = Math.ceil(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}import { memo } from 'react';
import { Text } from 'react-native';
// Keep the frequently re-rendering part tiny and memoised.
export const CountdownDigits = memo(function CountdownDigits({ ms }: { ms: number }) {
return (
<Text
style={{ fontSize: 56, fontWeight: '600', fontVariant: ['tabular-nums'] }}
accessibilityLiveRegion="polite"
accessibilityLabel={`${formatCountdown(ms)} remaining`}
>
{formatCountdown(ms)}
</Text>
);
});fontVariant: ['tabular-nums'] is the detail that makes a timer look finished. Proportional digits have different widths, so a 1 flipping to a 7 shifts every character after it and the whole number wobbles once a second. Tabular numerals are fixed-width and the digits stay put. It works with the system fonts on both platforms and with any custom font that ships tabular figures.
A progress ring with Reanimated
The ring should not be driven by the 250ms tick; that gives you a stepping animation. Instead, animate a shared value from the current fraction to zero over the remaining time, once, when the countdown starts, and let Reanimated run it on the UI thread at 60fps:
import { useEffect } from 'react';
import Svg, { Circle } from 'react-native-svg';
import Animated, { Easing, useAnimatedProps, useSharedValue, withTiming, cancelAnimation } from 'react-native-reanimated';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const SIZE = 220, STROKE = 12, R = (SIZE - STROKE) / 2, C = 2 * Math.PI * R;
export function CountdownRing({ remaining, duration, isRunning }:
{ remaining: number; duration: number; isRunning: boolean }) {
const progress = useSharedValue(remaining / duration); // 1 = full, 0 = done
useEffect(() => {
if (isRunning) {
// One animation for the whole remaining time. Linear, so it matches the clock.
progress.value = withTiming(0, { duration: remaining, easing: Easing.linear });
} else {
cancelAnimation(progress);
progress.value = remaining / duration; // snap to the paused value
}
}, [isRunning]); // intentionally not on every tick
const animatedProps = useAnimatedProps(() => ({
strokeDashoffset: C * (1 - progress.value),
}));
return (
<Svg width={SIZE} height={SIZE}>
<Circle cx={SIZE / 2} cy={SIZE / 2} r={R} stroke="rgba(255,255,255,0.12)" strokeWidth={STROKE} fill="none" />
<AnimatedCircle
cx={SIZE / 2} cy={SIZE / 2} r={R}
stroke="#f97316" strokeWidth={STROKE} fill="none" strokeLinecap="round"
strokeDasharray={C}
animatedProps={animatedProps}
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
/>
</Svg>
);
}When the app comes back from the background the Reanimated timing animation will also have been paused, so the ring can be behind the digits. The simple fix is to re-run the effect on resume: include the endsAt value from the hook in the dependency array and restart withTiming from the fresh fraction. If you would rather not own the SVG, react-native-countdown-circle-timer draws the same ring; keep the hook above as the source of truth and pass it the remaining seconds.
When it ends and the app is closed
No JavaScript of yours runs reliably while the app is backgrounded, so onComplete will fire late, on resume, if the user has left. The dependable signal is a local notification scheduled for the end time the moment the timer starts:
import * as Notifications from 'expo-notifications';
export async function scheduleEndNotification(endsAt: number) {
return Notifications.scheduleNotificationAsync({
content: { title: 'Time is up', body: 'Your timer has finished.', sound: true },
trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: new Date(endsAt) },
});
}
// On pause/reset/complete-in-foreground:
export function cancelEndNotification(id: string) {
return Notifications.cancelScheduledNotificationAsync(id);
}Schedule on start, cancel on pause or reset, and cancel again in onComplete so the user is not notified about a timer they watched finish. Permissions, the Android channel, and testing on a real device are covered in the Expo notifications guide; local notifications use the same setup minus the push token.
Libraries, if you want them
| Option | What it gives you | Pick it for | The cost |
|---|---|---|---|
Your own useCountdown hook | Timestamp-based logic, resume handling, formatting | Zero dependencies, exactly your behaviour | You draw the UI |
react-native-countdown-circle-timer | Animated circular ring with Reanimated, color stops | The ring UI without writing SVG math | Opinionated API; verify its resume behaviour in your app |
react-timer-hook | useTimer / useStopwatch hooks | Familiar if you used it on the web | Little beyond 40 lines of your own; check background handling |
expo-notifications | Local notification at the end time | The only reliable "it finished" signal when the app is closed | Permission prompt; test on a device, not the simulator |
The test for any timer library is the one from the first section: background the app for a minute and come back. If the display is wrong, the library decrements state instead of reading the clock, and no amount of styling will save it.
Skip the wiring
A timestamp-based hook, an AppState listener, tabular digits, a UI-thread ring, a local notification. It is a solved problem that still eats a day in every fitness, cooking, focus, and auction app that gets built. ShipNative generates a real Expo app from a description, with patterns like this one already correct, previews it on your phone, and hands you the full React Native project. Describe the timer screen you want and edit the hook like any other file you wrote.