Building a Reactive Timer
This post is also available on YouTube!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:
- We either add
time
to the dependency array ofuseEffect
, and have the effect rerun every second - 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.