Stopwatch timer is not accurate it is slower than the real time in react native expo

I am trying to build a stopwatch in react native expo which contains hours, minutes, seconds, and centiseconds which means 00:00:00:00, the issue that i am having is that the time that i am seeing on screen is slower than real stopwatch of google for example it is slower or faster but never the right time. for example when it is 2sec on google stopwatch it is 1sec on my time if slower or 3sec if faster. On the emulator the time is always slower so i test on the expo go or i build an apk and test on my real device. below are the codes that gave me the closest time at the beginning then when time increment it becomes faster than google stopwatch. I need your help please understand what is happening ? the time of setInterval() should be in milliseconds but it is not giving a right millis it is slower or faster.

Edit:
When i make the interval run after 100 millis, the result is right but when i make it 10millis it is not accurate

import React, { useState, useRef, useEffect, useCallback } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Image, Modal, Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { RadioButton } from 'react-native-paper';
import Ionicons from '@expo/vector-icons/Ionicons';
import { Video, ResizeMode } from 'expo-av';
import * as BackgroundFetch from 'expo-background-fetch';
import * as TaskManager from 'expo-task-manager';

export default function StopwatchScreen() {
    const [menuVisible, setMenuVisible] = useState(false);
    const [selectedOption, setSelectedOption] = useState(null);
    const [checked, setChecked] = useState('first');

    const radioOptions = ["Option 1", "Option 2", "Option 3"];

    const [isRunning, setIsRunning] = useState(false);
    const [time, setTime] = useState(0);
    const [results, setResults] = useState([]);
    const timer = useRef(null);

    const handleLapButtonPress = useCallback(() => {
        if(isRunning) {
            setResults((previousResults) => [time, ...previousResults]);
        } else {
            setResults([]);
            setTime(0);
        }
    }, [isRunning, time]);

    const handleStartButtonPress = () => {
        setIsRunning(!isRunning);
        console.log("time: ", time);
        console.log("isRunning: ", isRunning);
    };

    useEffect(() => {
        let timer;
        if(isRunning) {
            timer = setInterval(timeStart, 20);
        }
        
        return () => clearInterval(timer);
        
    }, [time, isRunning]);

    const timeStart = () => {
        setTime(prevStartTime => prevStartTime + 1);
        console.log(time);
    };

    const toggleMenu = () => {
        setMenuVisible(!menuVisible);
    }

    const padToTwo = (number) => (number <= 9 ? `0${number}` : number);

    const displayTime = (milliseconds) => {
        let hours = 0;
        let minutes = 0;
        let seconds = 0;
        let centiseconds = 0;

        let formatedMinutesSeconds;
        let formatedCentiseconds;
      
        //example 1000
        if (milliseconds < 0) {
            milliseconds = 0;
        }

        if (centiseconds < 100) {
            formatedCentiseconds = `${padToTwo(centiseconds)}`;
        }

        centiseconds = Math.floor(milliseconds / 10) ;
        let remainCentiseconds = centiseconds % 100;
        seconds = Math.floor(milliseconds / 1000);

        // millis = 55000;
        // centiseconds = 5510;
        // seconds = 55.1

        if (seconds < 60) {
            formatedCentiseconds = `${padToTwo(remainCentiseconds)}`;
            formatedMinutesSeconds = `00:${padToTwo(seconds)}`;
        } else {
            formatedCentiseconds = `${padToTwo(remainCentiseconds)}`;
            let remainSeconds = seconds % 60;
            minutes = (seconds - remainSeconds) / 60;
    
            if (minutes < 60) {
                formatedMinutesSeconds = `${padToTwo(minutes)}:${padToTwo(remainSeconds)}`;
            } else {
                let remainMinutes = minutes % 60;
                hours = (minutes - remainMinutes) / 60;
                formatedMinutesSeconds = `${padToTwo(remainMinutes)}:${padToTwo(remainSeconds)}`;
            }
        }
        return {
            hours: padToTwo(hours),
            minutesSeconds: formatedMinutesSeconds,
            centiseconds: formatedCentiseconds,
        };
    };
    
    return (
        
        <View style={[styles.container, Platform.OS === 'android' && styles.androidPadding]}>
        
            <View style={styles.header}>
                <Text style={[styles.title, styles.textFont]}>Futuristic Stopwatch</Text>
                <TouchableOpacity onPress={toggleMenu}>
                    <Image source={require('../assets/images/three_dots.png')} style={styles.dotsIcon} />
                </TouchableOpacity>
            </View>

            <Modal
                transparent={true}
                animationType="slide"
                visible={menuVisible}
                onRequestClose={() => setMenuVisible(false)}
            >
                <View style={styles.modalContainer}>
                    <View style={styles.modalContent}>
                        <Ionicons name="close-circle" size={32} color="#11167F" onPress={() => setMenuVisible(false)} style={styles.closeButton} />
                        <View style={styles.sectionTitle}>
                            <Text style={[styles.sectionTitleText, styles.textFont]}>Theme</Text>
                        </View>
                        <View style={styles.radioBox}>
                            <View style={styles.radioOptions}>
                                <RadioButton
                                    value="first"
                                    status={ checked === 'first' ? 'checked' : 'unchecked' }
                                    onPress={() => setChecked('first')}
                                    color="blue"
                                />
                                 <Text style={[styles.textFont]}>Blue</Text>
                            </View>
                            <View style={styles.radioOptions}>
                                <RadioButton
                                    value="second"
                                    status={ checked === 'second' ? 'checked' : 'unchecked' }
                                    onPress={() => setChecked('second')}
                                    color="#DE382A"
                                />
                                <Text style={[styles.textFont]}>Red</Text>
                            </View>
                        </View>
                    </View>
                </View>
            </Modal>

            <View style={styles.contentContainer}>
                
                <View style={styles.rowHud}>

                    {/* <Text style={[styles.timerOverlay, styles.textFont]}>00:00</Text> */}
                    <Text style={[styles.hoursTimerOverlay, styles.textFont]}>{`${displayTime(time).hours}`}</Text>
                    <Text style={[styles.timerOverlay, styles.textFont]}>{time}</Text>
                    <Text style={[styles.centisecondsTimerOverlay, styles.textFont]}>{`${displayTime(time).centiseconds}`}</Text>
                </View>
                <View style={styles.controls}>
                    <TouchableOpacity onPress={handleStartButtonPress} style={[styles.controlButtonBorder, { backgroundColor: isRunning ? "#340e0d" : "#0a2a12" }]}>
                        <View style={styles.controlButton}>
                            <Text style={{ color: isRunning ? "#ea4c49" : "#37d05c" }}>{isRunning ? 'Stop' : 'Start'}</Text>
                        </View>
                    </TouchableOpacity>
                    <TouchableOpacity onPress={handleLapButtonPress} style={[styles.controlButtonBorder, { backgroundColor: isRunning ? "#333333" : "#1c1c1e" }]}>
                        <View style={styles.controlButton}>
                            <Text style={{ color: isRunning ? "#fff" : "#9d9ca2" }}>{isRunning ? 'Lap' : 'Reset'}</Text>
                        </View>
                    </TouchableOpacity>
                </View>
            </View>
        </View>
    );

    
}

const styles = StyleSheet.create({
    container: {
      flex: 1,
      backgroundColor: '#fff',
      padding: 0,
    },
    androidPadding: {
        paddingTop: Platform.OS === 'android' ? 25 : 0,
    },
    header: {
        flexDirection: 'row',
        justifyContent: 'space-between',
        alignItems: 'center',
        marginBottom: 0,
        paddingTop: 10,
        paddingBottom: 10,
        paddingLeft: 10,
        paddingRight: 10,
        backgroundColor: '#0a1055',
    },
    title: {
        fontSize: 18,
        //fontWeight: 'bold',
        color: 'white',
    },
    dotsIcon: {
        width: 20,
        height: 20,
        resizeMode: 'contain',
    },
    modalContainer: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: 'rgba(0, 0, 0, 0.5)',
    },
    textFont: {
        fontFamily: 'Orbitron Black',
    },
    modalContent: {
        backgroundColor: '#fff',
        padding: 20,
        borderRadius: 10,
        elevation: 5,
    },
    closeButton: {
        position: 'absolute',
        top: 10,
        right: 10,
    },
    sectionTitle: {
        flexDirection: 'row',
        alignItems: 'center',
        marginTop: 10,
    },
    sectionTitleText: {
        fontFamily: 'Orbitron Black',
    },
    radioBox: {
        marginTop: 20,
        alignItems: 'flex-start',
        width: 300,
    },
    radioOptions: {
        flexDirection: 'row',
        alignItems: 'center',
        marginBottom: 10,
    },

    contentContainer: {
        flex: 1,
        backgroundColor: '#0E1B84',
    },
    rowArrow: {
        flex: 0.1,
        flexDirection: 'row',
        marginBottom: 0,
    },
    rowHud: {
        flex: 0.5,
        flexDirection: 'row',
        marginBottom: 0
    },
    timerOverlay: {
        position: 'absolute',
        top: '50%', // Adjust as needed
        left: '50%', // Adjust as needed
        transform: [{ translateX: -90 }, { translateY: -30 }],
        fontSize: 50,
        color: 'white',
    },
    hoursTimerOverlay: {
        position: 'absolute',
        top: '35%', // Adjust as needed
        left: '50%', // Adjust as needed
        transform: [{ translateX: -30 }, { translateY: -30 }],
        fontSize: 35,
        color: 'white',
    },
    centisecondsTimerOverlay: {
        position: 'absolute',
        top: '71%', // Adjust as needed
        left: '50%', // Adjust as needed
        transform: [{ translateX: -30 }, { translateY: -30 }],
        fontSize: 35,
        color: 'white',
    },
    controls: {
        flexDirection: 'row',
        justifyContent: 'space-between',
        paddingLeft: 20,
        paddingRight: 20,
    },
    controlButtonBorder: {
        justifyContent: "center",
        alignItems: "center",
        width: 70,
        height: 70,
        borderRadius: 70,
    },
    controlButton: {
        justifyContent: "center",
        alignItems: "center",
        width: 65,
        height: 65,
        borderRadius: 65,
        borderColor: "#000",
        borderWidth: 1
    }
});

Demo link – https://snack.expo.dev/cECs-YK53

First you should remove, the time from the useEffect dependency array. Because, inside setInterval you are calling setTime function which would cause a re-render. Now the value of time has changed, so the useEffect will run again and creates a new setInterval and this goes like an calling infinite setInterval unless you stop.

You should call setInterval only when the start button is pressed and clear it when the stop button is pressed. To clear the interval on button press you can store the return value of setInterval in a ref.

useEffect(() => {
    if(isRunning) {
        timerId.current = setInterval(timeStart, 10);
    }
    return () => clearInterval(timerId.current);
}, [isRunning]);

Next, in the setInterval instead of 20 as delay, use 10 as the delay since you need to display centiseconds aslo. (1 centiseconds = 10 milliseconds)

const handleStartButtonPress = () => {
    setIsRunning(isRunning => {
      if(isRunning && timerId.current){
        clearTimeout(timerId.current)
      }
      return !isRunning
    });
    console.log("time: ", time);
    console.log("isRunning: ", isRunning);
};

And then displayTime function can be simplified more if you need:

// @param - centiSeconds refers to `time` stored in state
const displayTime = (centiSeconds) => {
  let newCentiSeconds = centiSeconds % 100;
  let seconds = Math.floor(centiSeconds / 100) % 60;
  let minutes =  Math.floor(centiSeconds / (100 * 60)) % 60;
  let hours = Math.floor(centiSeconds / (100 * 60 * 60)) % 12;
  return {
      hours: hours.toString().padStart(2,0),
      minutes: minutes.toString().padStart(2,0),
      seconds: seconds.toString().padStart(2,0),
      centiSeconds: newCentiSeconds.toString().padStart(2,0),
  };
};

const {hours,minutes,seconds,centiSeconds} = displayTime(time)
return(
...
<Text style={[styles.hoursTimerOverlay, styles.textFont]}>{hours}</Text>
<Text style={[styles.timerOverlay, styles.textFont]}>:{minutes}</Text>
<Text style={[styles.timerOverlay, styles.textFont]}>:{seconds}</Text>
<Text style={[styles.centisecondsTimerOverlay, styles.textFont]}>:{centiSeconds}</Text>
...
)

Leave a Comment