Tech Notes

WepaDev

Counter Button Of Doom

I stumbled during a technical interview. "Using React, create a counter that increments on click and decrements every second."

Feb 13, 2023
~3 min read

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

inhale the confidence gif 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:
  1. countDown takes in a "setter" function and a value that will represent the current value.
  2. countDown triggers a setTimeout for 1000ms. After 1000ms, the callback passed in will be pushed onto the execution stack of the event loop.
  3. The callback will call setCounter with a value of value - 1 if the current value is greater than 0.
  4. countDown then gets called again with the same setCounter and value for counter.
  5. 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
  1. countDown now takes in another function called getCounter, instead of the argument counter which was of type number.
  2. getCounter is now used to fetch the current value of counter.
  3. In our component, getCounter acts as a getter function that retrieves the value of counter.
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.

dog reaching for what he things is a bone

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.

  1. Passing in a value
  2. 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