Creating a Custom Hook - useCountdown

So far in part 1, we've looked at how to set up useReducer to track the state of our simple Math quiz. However, we now want to display a timer that counts down the remaining time on the clock before the end of the question, and triggers the next question if an answer has not been given. Do that we're going to write a custom hook and get a closer look at how useEffect and useRef works.

Timers in JavaScript

In JavaScript, there are two functions that allow us to trigger a function after a lapse of time. The setTimeout and setInterval have a very similar calling signature :

    const timer1 = setTimeout(() => { /* the callback */ }, delay);
    const timer2 = setInterval(() => { /* the callback */ }, delay, );

The difference between the two is simply that setTimeout calls the callback once, while setInterval will keep going until the timer is cleared (with clearInterval). As we want to count down from e.g. 10 to 0, the setInterval function seems the most natural fit. The delay parameter is in milliseconds; if we want our timer to trigger every second, the delay will be 1000.

When the setTimeout or setInterval timers are done (when we don't need them anymore) we stop them using clearTimer or clearInterval respectively. If we were writing this in vanilla Javascript we'd be doing something along the lines of :

    let counter = 10 
    // ... //
    const timer = setInterval(() => {
      counter = counter - 1;
    }, 1000);

    //...//

    if (counter == 0) {
      clearInterval(timer);
      // and trigger the next question //
    }

What happens if we just plop that code into a React component? (I'll give you a hint: nothing good!)

Using setInterval (Level 0)

Let's set up a basic page in NextJS that uses the above code more or less directly :

    const Timer: React.FC<void> = () => {

      let [counter, setCounter] = useState(10);

      const timer = setInterval(() => {
        setCounter(counter - 1);
      }, 1000);

      if (counter == 0) {
        clearInterval(timer);
      }

      return <span>
         Time Left {counter}<br/>
         { (counter == 0) && <>All done</>}
       </span>;
    }

    export default Timer;

If we do this all hell breaks loose. As soon as the first step in the process starts, the time state changes, which triggers a new render, which **creates a new timer. **We now have two timers counting down and as soon as any of them triggers again, a new render is run and a new timer is created. So after two seconds, we have four timers, after three seconds we have eight, and so on. Thankfully we have a way to set up our component so that code is only triggered on the first render. To do so we use the second parameter of useEffect, the dependencies array. This lists the values on which useEffect triggers a side effect. If the second parameter is an empty array, useEffect will only trigger once, on the first render:

    useEffect(() => {
       // run code once
    }, []);// because we are using an empty array 

Photo by Negative Space

Adding useEffect (Level 1)

Let's encapsulate our timer in a useEffect hook and see how that does. Now it's worth noting here that useEffect allows us to return a cleanup function. This function is not called after the first run of useEffect, but it is called prior to any subsequent calls to useEffect or before the component is unmounted. This makes it a nice place to clean up our timer using clearInterval:

    const Timer: React.FC<void> = () => {



      let [counter, setCounter] = useState(10);



      useEffect(() => {
        const _timer = setInterval(() => {
          if (counter == 0) {
            clearInterval(_timer);
          } else {
            setCounter(counter -1);
          }
        }, 1000);
        return () => clearInterval(_timer); // clean up 
      }, []);



      return <span> 
         Time Left {counter}<br/>
         { (counter == 0) && <>All done</>}
       </span>;

    }

    export default Timer;

If we run this, the timer decrements exactly… once. Why is this? Well if we add a log inside the timer we can see that the timer triggers just fine. However, in the context of the setInterval callback, the counter variable is always worth its initial value. Why? Because the callback's environment — i.e. the value it "knows" the counter variable has — is set when the callback function is defined, on the first run when counter was worth its initial value. To use the technical term, this function is a **closure **— because it encloses (or remembers) the current state of its environment.

So… how do we define a variable that we can pass into the closure, that stays the same through the component lifecycle and yet that stores a reference to the "current value" of the counter?

Well, thankfully there is a hook for that: useRef.

Adding useRef (level 2)

Basically, useRef is a function that always returns the same JavaScript Object throughout the component's lifecycle(in that sense it's kind of the opposite of useState!). That object can then be used as a reference to store the value of a current variable. This allows us to communicate the current value of the counter to the environment within our closure, which now contains the initial value of both the counter (which we don't care about) and the reference object created by useRef.

Our code now looks like this:

    const Timer: React.FC<void> = () => {



      let [counter, setCounter] = useState(10);
      const intervalRef = useRef<number>();
      intervalRef.current = counter



      useEffect(() => {
        const _timer = setInterval(() => {
          if (intervalRef.current  == 0) {
            clearInterval(_timer);
          } else {
            setCounter(intervalRef.current -1);
          }
        }, 1000);
        return () => clearInterval(_timer);
      }, []);



      return <span>
        Time Left {counter}<br/>
        { (counter == 0) && <>All done</>}
       </span>;
    }



    export default Timer;

All this is good and well, but what if we want to actually use this inside our quiz? That is to say, how can we have a reusable bit of code that can pull in this behavior to use in our components? Let's create a custom hook!

Key Takeways

We've set up our counter to start at a predefined level (10) and to do something specific (i.e. call setDone(true) ) when the counter hits 0. However, what if we want to do something else like trigger the next question? If we want to create a reusable piece of code, we want to be able to :

  • define an** initial value** for the hook to count down from (e.g. 10)
  • define a callback action for the hook to carry out if the countdown reaches 0 (e.g. triggerNextAnswer)
  • define a way to restart the countdown (e.g. when a new answer is shown)
  • have a way to **interrupt the countdown **(in our example, when the player answers a question before the countdown reaches 0)
  • provide the current value of the countdown for the component to display

so the shape we want our countdown hook to have is something like this:

    const [counter, interrupt] = useCountdown(initialValue: number, onComplete:()=>void, restartValues:Array<any>)

How do we go about this? Well, a custom hook is nothing more than a **function that wraps other hooks. **So let's start by simply wrapping our current hooks into a function:

    const useCountdown = (initialValue: number) => {



      let [counter, setCounter] = useState(initialValue);
      const intervalRef = useRef<number>();
      intervalRef.current = counter
      
      useEffect(() => {
        const _timer = setInterval(() => {
          if (intervalRef.current  == 0) {
            clearInterval(_timer);
          } else {
            setCounter(intervalRef.current -1);
          }
        }, 1000);
        return () => clearInterval(_timer);
      }, []);



     return [counter];
    }

Our component now looks like this:

    const Timer: React.FC<void> = () => {
      const [counter] = useCountdown(10);
      return <span>
        Time Left {counter}<br/>
        { (counter == 0) && <>All done</>}
      </span>;
    }

Which is a lot cleaner.

Now we just need to add in the callbackAction and the values to retrigger the countdown on (in our quiz example this would be the current question index for example), and to return the *interrupt *function which clears the timer. To do this we need to store the timer in the state :

    const useCountDown = (
        initialValue: number, 
        onComplete:() => void,
        restartValues:Array<any>) => {

      let [counter, setCounter] = useState(initialValue);
      let [timer, setTimer] = useState<NodeJS.Timeout>(null);
      const intervalRef = useRef<number>();
      intervalRef.current = counter

      const interrupt = () => {
        clearInterval(timer);
      };

      useEffect(() => {
        setCounter(initialValue);
        const _timer = setInterval(() => {
          if (intervalRef.current  == 0) {
            onComplete();
            clearInterval(_timer);
          } else {
            setCounter(intervalRef.current -1);
          }
        }, 1000);
        setTimer(_timer);
        return () => clearInterval(_timer);

      }, restartValues); // trigger new timer

      return [counter, interrupt]; // return interrupt

    };

And voilà, we now have our custom useCountdown hook we can use in our path quiz!

Social
Made by kodaps · All rights reserved.
© 2023