31

I am trying to render a count down timer on screen with react hooks, but I am not sure what is the best way to render it.

I know I am supposed to use the useEffect to compare current state to previous state, but I do not think I am doing it correctly.

I would appreciate the help!

I have tried a couple of different ways, none of them work, like setting a state whenever it updates, but it just ends up flickering like crazy.



const Timer = ({ seconds }) => {
    const [timeLeft, setTimeLeft] = useState('');

    const now = Date.now();
    const then = now + seconds * 1000;

    const countDown = setInterval(() => {
        const secondsLeft = Math.round((then - Date.now()) / 1000);
        if(secondsLeft <= 0) {
            clearInterval(countDown);
            console.log('done!');
            return;
        }
        displayTimeLeft(secondsLeft);
    }, 1000);

    const displayTimeLeft = seconds => {
        let minutesLeft = Math.floor(seconds/60) ;
        let secondsLeft = seconds % 60;
        minutesLeft = minutesLeft.toString().length === 1 ? "0" + minutesLeft : minutesLeft;
        secondsLeft = secondsLeft.toString().length === 1 ? "0" + secondsLeft : secondsLeft;
        return `${minutesLeft}:${secondsLeft}`;
    }

    useEffect(() => {
        setInterval(() => {
            setTimeLeft(displayTimeLeft(seconds));
        }, 1000);
    }, [seconds])
    

    return (
        <div><h1>{timeLeft}</h1></div>
    )
}

export default Timer;```
2

5 Answers 5

83
const Timer = ({ seconds }) => {
  // initialize timeLeft with the seconds prop
  const [timeLeft, setTimeLeft] = useState(seconds);

  useEffect(() => {
    // exit early when we reach 0
    if (!timeLeft) return;

    // save intervalId to clear the interval when the
    // component re-renders
    const intervalId = setInterval(() => {
      setTimeLeft(timeLeft - 1);
    }, 1000);

    // clear interval on re-render to avoid memory leaks
    return () => clearInterval(intervalId);
    // add timeLeft as a dependency to re-rerun the effect
    // when we update it
  }, [timeLeft]);

  return (
    <div>
      <h1>{timeLeft}</h1>
    </div>
  );
};
Sign up to request clarification or add additional context in comments.

6 Comments

Does it make more sense to use setTimer here?
@AmirShitrit Do you mean setTimeout? Both are valid options but setTimeout suppose to trigger the function once while setInterval suppose to trigger the function every x amount of time. because of the nature of useEffect where we need to set and clear the timer every time timeLeft changes i guess it doesn't really act like a "real" setInterval and i can see your point of setTimeout in this case.
@AmirShitrit Yes, I prefer setTimeout
the thing i like most about this is that it works.
Could be made more robust against setInterval drift by calculating a Date.now() - start delta instead of subtracting one second at a time.
|
17

You should use setInterval. I just wanted to add a slight improvement over @Asaf solution. You do not have to reset the interval every time you change the value. It's gonna remove the interval and add a new one every time (Might as well use a setTimeout in that case). So you can remove the dependencies of your useEffect (i.e. []):

function Countdown({ seconds }) {
  const [timeLeft, setTimeLeft] = useState(seconds);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setTimeLeft((t) => t - 1);
    }, 1000);
    return () => clearInterval(intervalId);
  }, []);

  return <div>{timeLeft}s</div>;
}

Working example:

Countdown example

Note in the setter, we need to use this syntax (t) => t - 1 so that we get the latest value each time (see: https://reactjs.org/docs/hooks-reference.html#functional-updates).


Edit (22/10/2021)

If you want to use a setInterval and stop the counter at 0, here is what you can do:

function Countdown({ seconds }) {
  const [timeLeft, setTimeLeft] = useState(seconds);
  const intervalRef = useRef(); // Add a ref to store the interval id

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setTimeLeft((t) => t - 1);
    }, 1000);
    return () => clearInterval(intervalRef.current);
  }, []);

  // Add a listener to `timeLeft`
  useEffect(() => {
    if (timeLeft <= 0) {
      clearInterval(intervalRef.current);
    }
  }, [timeLeft]);

  return <div>{timeLeft}s</div>;
}

Countdown example

5 Comments

The interval will run infinitely
You could stop the counter by unmounting the component if you want to stop at 0: {seconds > 0 && <Countdown seconds={seconds} />}. But true, depending on the requirements, it might need some adjustments. Here it won't update the interval value if you update the seconds in props either. I just wanted to present a valid alternative to setTimeout, using setInterval (and not resetting it each render).
That's the thing, the parent doesn't know anything about timeLeft, updating the interval on each re-render is perfectly fine and gives you options to do things when timeLeft reaches a certain point like 0. You could do it inside the state updater callback but that's just ugly in my opinion
ESLint has a rule react-hooks/exhaustive-deps that enforces adding the dependency of timeLeft in the array. But still, agree with not having to clear the interval every time. I think in that case I'll have to use setTimeout
Will this actually be exact? Isn't there some tiny amount time passing between the next execution of useEffect?
5

Here's another alternative with setTimeout

const useCountDown = (start) => {
  const [counter, setCounter] = useState(start);
  useEffect(() => {
    if (counter === 0) {
      return;
    }
    setTimeout(() => {
      setCounter(counter - 1);
    }, 1000);
  }, [counter]);
  return counter;
};

Example

Edit fragrant-currying-512ky

Comments

2

Here is a small component - CountdownTimer - accepting an input parameter expiresIn representing the time left in seconds.

We use useState to define min and sec which we display on the screen, and also we use timeLeft to keep track of the time that's left.

We use useEffect to decrement timeLeft and recalculate min and sec every second.

Also, we use formatTime to format the minutes and seconds before displaying them on the screen. If minutes and seconds are both equal to zero we stop the countdown timer.

import { useState, useEffect } from 'react';


const CountdownTimer = ({expiresIn}) => {
    const [min, setMin] = useState(0);
    const [sec, setSec] = useState(0);
    const [timeLeft, setTimeLeft] = useState(expiresIn);

    const formatTime = (t) => t < 10 ? '0' + t : t;

    useEffect(() => {
        const interval = setInterval(() => {
            const m = Math.floor(timeLeft / 60);
            const s = timeLeft - m * 60;

            setMin(m);
            setSec(s);
            if (m <= 0 && s <= 0) return () => clearInterval(interval);

            setTimeLeft((t) => t - 1);
          }, 1000);

          return () => clearInterval(interval);
    }, [timeLeft]);

    return (
        <>
            <span>{formatTime(min)}</span> : <span>{formatTime(sec)}</span>
        </>
    );
}

export default CountdownTimer;

Optionally we can pass a setter setIsTerminated to trigger an event in the parent component once the countdown is completed.

const CountdownTimer = ({expiresIn, setIsTerminated = null}) => {
    ...

For example, we can trigger it when minutes and seconds are both equal to zero:

if (m <= 0 && s <= 0) {
    if (setTerminated) setIsTerminated(true);
    return () => clearInterval(interval);
}

Comments

1

Here's my version of a hook, with a "stop" countdown. Also, I added a "fps" (frames p/sec), to show the countdown with decimal places!

import { useEffect, useRef, useState } from 'react'

interface ITimer {
    timer: number
    startTimer: (time: number) => void
    stopTimer: () => void
}

interface IProps {
    start?: number
    fps?: number
}

const useCountDown = ({ start, fps }: IProps): ITimer => {
    const [timer, setTimer] = useState(start || 0)
    const intervalRef = useRef<NodeJS.Timer>()

    const stopTimer = () => {
        if (intervalRef.current) clearInterval(intervalRef.current)
    }

    const startTimer = (time: number) => {
        setTimer(time)
    }

    useEffect(() => {
        if (timer <= 0) return stopTimer()
        intervalRef.current = setInterval(() => {
            setTimer((t) => t - 1 / (fps || 1))
        }, 1000 / (fps || 1))
        return () => {
            if (intervalRef.current) clearInterval(intervalRef.current)
        }
    }, [timer])

    return { timer, startTimer, stopTimer }
}

export default useCountDown

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.