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:
countDowntakes in a "setter" function and a value that will represent the current value.countDowntriggers 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
setCounterwith a value ofvalue - 1if the current value is greater than 0. countDownthen gets called again with the samesetCounterand value forcounter.- To tie it all together I kicked it off in a
useEffectwithin 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
countDownnow takes in another function calledgetCounter, instead of the argumentcounterwhich was of typenumber.getCounteris now used to fetch the current value ofcounter.- In our component,
getCounteracts 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
setIntervalis best for code you want to repeat after a certain number of milliseconds.setStatecan 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