a red apple shooting an arrow towards a clock, bullseye

Building a Reactive Timer

This post is also available on YouTube!
Building a timer doesn't sound exciting, but we have to start somewhere, right? I promise there is fun and intrigue hidden behind the all this simplicity.

Let's say we want to create a simple timer that counts down from 30 minutes to zero by one second.

In an imperative language we would do something like this:

time = 30 * 60

while time > 0 {
    wait_a_second()
    time -= 1
    render(app(time))
}

So start with setting up a variable time, where we put in 30 minutes in seconds.

Then a simple loop.

Wait a second, subtract 1 and render.

You could write a timer like this one in python easily (let's ignore the details of format_time for now):

time = 30 * 60

while time > 0:
    time.sleep(1)
    time -= 1
    print(format_time(time))

Works beautifully in this simple context, but apps are usually much more complex. It's not only the timer that happens. User might want to press a button here and there, maybe go to another page and then come back, and the whole damn thing should still work.

Javascript, being a slightly more functional language, doesn't love the wait a second part.

Instead we have something a bit harder to understand, but in many ways, nicer to work with.

setInterval

Javascript's beautiful time managing contraption.

It takes a function and a delay, pops out an ID, and then runs the function every once in a while (where we get to specify the details behind both "once" and "in a while").

time = 30 * 60

const interval = setInterval(() => {
    time -= 1
    console.log(format_time(time))
}, 1000)

The ID grants us the power to stop the interval at a whim.

That's so much better than what we could do in python!

Python code was synchronous, so there was no way to stop it without closing the whole program.

Our js code allows us to do this:

time = 30 * 60

const interval = setInterval(() => {
    time -= 1
    console.log(format_time(time))
}, 1000)

if(prompt('stop?')) {
    clearInterval(interval)
}

So much more control!

But it isn't an app yet. We still need to render the app, right?

React

Ok, I admit, I'm a bit of a tease. We still need to learn one thing before we can start coding the React app.

Callbacks

Functions that we pass to some other code are called callbacks.

I think it's because their supposed to be "called back".

Yeah, I know. At least its easy to remember.

Oh, to make things more confusing, there are cases where you pass a function to other function and I wouldn’t call it a callback.

Callbacks usually don't return anything, and instead they do something on the side (a side effect).

React (this time for real)

"use client";
import { useEffect, useState } from "react";

const formatTime = (time: number) => {
  const minutes = Math.floor(time / 60);
  const seconds = time % 60;
  return `${minutes}:${seconds.toFixed(0).padStart(2, "0")}`;
};

const Timer = () => {
  const [time, setTime] = useState(30 * 60);
  const [isRunning, setIsRunning] = useState(true);

  useEffect(() => {
    if (time === 0) {
      setIsRunning(false);
    }
  }, [time]);

  useEffect(() => {
    if (!isRunning) {
      return;
    }

    const interval = setInterval(() => {
      setTime((t) => t - 1);
    }, 1000);

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

  return <div>{formatTime(time)}</div>;
};

export default Timer;

Are you still there?

Yay! 🎉

Let's break it down.

useState

Instead of variables, we have useState.

const [time, setTime] = useState(30 * 60);
const [isRunning, setIsRunning] = useState(true);

I would bet you already know what useState does, but in case you forgot, let's have a quick recap.

While in our imperative example, we rendered the app manually, React on the other prefers to decide when to render for you.

This style is called declarative, as opposed to imperative.

useState hook returns two things, a value and a setter.

It seems obvious, but there are two roles, that useState plays.

First, it provides us with a value, and second, it provides us with a way to change that value.

And while it feels like you could just write:

time -= 1;

You can't.

In javascript, as opposed to magical Rust, there would be no way for our app to know that you changed the value.

setTime is a function, and functions can do just about anything.

We call it a setter, because it sets the value, but it also does inform React that something changed.

This approach is called reactive programming, and it's precisely why React is called React.

We also have an isRunning state.

This one is here to make our code a bit nicer, and it will allow you to add more features on your own, later.

useEffect

Most of the logic in this code is in the useEffects.

useEffects allow us to react to changes in the state, but they are tricky.

There is one important concept you'll need to focus on when dealing with useEffect, and its called idempotence.

If something is idempotent it means that running it once is the same as running it multiple times.

Because useEffect runs side effects, we need to make sure that it's idempotent. Otherwise a tiny mistake here and there, could lead to a huge mess of unwieldy side effects.

Luckily the first one already is.

useEffect(() => {
  if (time === 0) {
    setIsRunning(false);
  }
}, [time]);

It makes sure that the timer stops when the time is equal to 0.

Even if somehow this useEffect runs twice, isRunning can't get more false than false.

The second argument to useEffect is called a dependency array. It contains all the values that the effect depends on. Whenever any of those values change, the effect will run again.

This useEffect will run whenever time changes, so about once a second.

Let's look at the second one:

useEffect(() => {
  if (!isRunning) {
    return;
  }

  const interval = setInterval(() => {
    setTime((t) => t - 1);
  }, 1000);

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

It sets up an interval that will run every second, decreasing the time by 1 each time it runs.

Remember, useInterval returns an ID of the interval immediately, and sets up the interval in the background.

You might notice that setting up the interval like that is not idempotent. Two intervals at the same time, means time gets decreased twice as fast.

This is why we end our useEffect with a cleanup function.

Any function returned from useEffect will be called before the effect is run again or before the component is unmounted. This makes our useEffect idempotent, as even if it runs twice, it will remove the old interval before setting up a new one.

So many callbacks

In the snippet above there's four callbacks. Three of them don't even have arguments.

There's a reason why we would want to use callbacks without arguments.

It happens when we want to control when the code will run.

  • In setInterval, we want the code to be repeated at a regular interval.

  • In useEffect, we want the code to run when the component is mounted, or when the values in the dependency array change.

  • In a cleanup to useEffect we want the code to run when the component is unmounted, or before this useEffect retriggers.

  • You might also often see similar callbacks used for effects that happen in reaction to DOM events like onClick

Remember, a callback without arguments means we want to control WHEN the code will run.

Tricks around setters

Callback in setTime is a bit different.

Usually we would put a value in a setter, like this:

setTime(time - 1);

But then we're at left with two fairly bad options:

  1. We either add time to the dependency array of useEffect, and have the effect rerun every second
  2. Or we omit time and have the linter scream at us with a mean yellow squiggly.

Choose your adventure:

1. You add time to the dependency array

useEffect(() => {
  if (!isRunning) {
    return;
  }

  const interval = setInterval(() => {
    setTime(time - 1);
  }, 1000);

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

This might seem like it works at first.

The problem is running useEffect every second, means creating a new interval every second. Yes, the cleanup protects us from breaking idempotence, and having two intervals at the same time, but we lose any promise of precision, that setInterval gives us.

2. You omit time from the dependency array

useEffect(() => {
  if (!isRunning) {
    return;
  }

  const interval = setInterval(() => {
    setTime(time - 1);
  }, 1000);

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

Linter is right, this is a worse option.

React Hook useEffect has a missing dependency: 'time'. Either include it or remove the dependency array. You can also do a functional update 'setTime(t => ...)' if you only need 'time' in the 'setTime' call.

This is a very helpful message, yey linters!

Removing time from the dependency array means we don't rerun the effect when time changes, but we still access it in our code.

But doesn't the interval update time?

Yes, but notice how a component is a function. When you render a component, its function runs, does some things, and gets access to its locally scoped variables.

If a state changes, the old useEffect will still have access to the old value of time and continue to try to subtract 1 second from 30 minutes, over and over again. 29:59 forever.

3. You do a functional update

useEffect(() => {
  if (!isRunning) {
    return;
  }

  const interval = setInterval(() => {
    setTime((t) => t - 1);
  }, 1000);

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

Now were talking! There was never need to include time in this useEffect.

When dealing with reactivity, you have to often think about what do you need to know at a given time, and usually the less you know, the better.

You don't need to know the current value of time to tell the setter what to do with it.

Formatting time

function formatTime(time: number) {
  const minutes = Math.floor(time / 60);
  const seconds = time % 60;

  return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

Now the easy part. Feel free to skip it if you know how the code works.

To divide with a remainder in javascript we have to separately divide, and get a remainder.

Regular division gets us a number with a fraction, so we need to drop the fraction with Math.floor.

We can get a remainder with % as in any other programming language.

Then a simple template string with padding for seconds, and we're done. 🎉

And what a timer we have!

Let's quickly recap.

Our first useEffect will turn off the timer when it reaches 0:00.

Try adding buttons to restart the timer, or to pause it.

Our second useEffect creates an interval, that repeatedly subtracts 1 from our time state, using a setter with a callback, that gets cleaned up afterwards. This useEffect only runs when the timer gets stopped or restarted, not every second.

And our formatTime function formats the time nicely.