Counter Button Of Doom
I stumbled during a technical interview. "Using React, create a counter that increments on click and decrements every second."
Everyone has their days, and during this one technical interview, my brain was scrambled. I will walk through my initial approach, the reasons why it was wrong, and what I should have done. Here was the exercise:
Using React, create a Counter that increments upon clicking a button. For every second the counter will also be decrementing by 1.
Starting With A Win
Initially, this seemed straightforward. I started by creating a component that updates a state variable upon click. This I can do in my sleep, no problem.
const Counter = () => { const [counter, setCounter] = useState<number>(0); /* * Callback that is responsible for * incrementing the state by 1 */ const handleClick = () => { setCounter(counter + 1) } return ( // When button is clicked we trigger 'handleClick' <button onClick={handleClick}> {counter} </button> ); }
Now For The Hard Part
For every second that passes the counter will decrement by 1.
For the second requirement, I decided to put a function outside of my component. This function would determine how to update the state. The other reason, I wanted to use setTimeout and trigger a delayed state change. From past experiences when using setTimeout, you need to keep in mind the scope and time of execution. To understand this better read this post
const countDown = ( setCounter: (num: number) => void, value: number ) => { setTimeout(() => { // Don't go into negative values if (value > 0) { setCounter(value - 1) } // Decrement again countDown(setCounter, value) }, 1000) } const Counter = () => { ... useEffect(() => { countDown(setCounter, counter) }, []) ... }
What is happening here:
countDown
takes in a "setter" function and a value that will represent the current value.countDown
triggers a setTimeout for 1000ms. After 1000ms, the callback passed in will be pushed onto the execution stack of the event loop.- The callback will call
setCounter
with a value ofvalue - 1
if the current value is greater than 0. countDown
then gets called again with the samesetCounter
and value forcounter
.- To tie it all together I kicked it off in a
useEffect
within the Counter component.
Why It Is Wrong: Initial Render
On the initial render, we are calling countDown
and passing in setCounter
. On mount, the current value for counter
is 0.
useEffect(() => { countDown(setCounter, counter) }, [])
Since the initial counter is 0, we are never decrementing the counter and we are continuously calling countDown
with the same values. At no point are we getting the latest value of counter
.
Pass Me The Shovel
I zeroed in on the fact that I needed to pass in an updated value of counter
and continued to dig myself into a deeper hole.
const countDown = ( setCounter: (num: number) => void, getCounter: () => number, ) => { setTimeout(() => { // Don't go into negative values const currentNumber = getCounter() if (currentNumber > 0) { setCounter(currentNumber - 1) } // Decrement again countDown(setCounter, getCounter) }, 1000) } const Counter = () => { ... const getCounter = () => counter useEffect(() => { countDown(setCounter, getCounter) }, []) ... }
What is happening here
countDown
now takes in another function calledgetCounter
, instead of the argumentcounter
which was of typenumber
.getCounter
is now used to fetch the current value ofcounter
.- In our component,
getCounter
acts as a getter function that retrieves the value ofcounter
.
Why It Is Wrong: Old References
When a react component gets updated, it will re-render. During that re-rendering process, functions are re-initialized. Therefore, getCounter
becomes a new function when counter
changes.
Hence, the function that is passed into countDown
is never updated and never provides the current true value of counter
.
Regardless, on the initial mount, countDown
gets the value of zero and will never trigger an update. This update has no impact and we reached our point of doom.
The Solution
const countDown = (decrementCounter: () => void) => { setInterval(decrementCounter, 1000) } const Counter = () => { const [counter, setCounter] = useState<number>(0); useEffect(() => { countDown(() => { setCounter((currentValue) => { return currentValue > 0 ? currentValue - 1 : currentValue }) }) }, []) const handleClick = () => { setCounter(counter + 1) } return ( <button onClick={handleClick}> {counter} </button> ); }
See implementation on: CodePen
SetInterval
setInterval(decrementCounter, 1000)
While I was recursively calling countDown
to trigger another setTimeout
call, I could have used setInterval. setInterval
takes in the same parameters as setTimeout
; a callback function, and a number representing milliseconds. This will invoke the callback after every 1000ms. This makes our code easier to understand as it then becomes a one-liner.
setCounter Callback
setCounter((currentValue) => { return currentValue > 0 ? currentValue - 1 : currentValue })
The setState
function returned from the useState
callback can be used in two different ways.
- Passing in a value
- Passing in a function that determines the next state based on the previous state.
Previously, we were calling the setCounter
function and passing a value. If we use the callback, we will always have the previous state without having to reference a value or create a getter function. All we do is move the logic of when to decrement within the setCounter
callback and ensure that the setInterval
is given a function that will invoke setCounter
.
Takeaways
setInterval
is best for code you want to repeat after a certain number of milliseconds.setState
can take in a value or a callback that returns a calculated value- Re-renders will instantiate variables and functions. Passing these as references can be risky