Using callbacks in custom hooks

2 mins |

November 20, 2020

React hooks have made sharing application logic easier. We achieve a lot of that by creating custom hooks. In this blog, we will talk about using callbacks in a custom hook. It is powerful, and it comes with some caveats. I wouldn’t call it caveats, it comes with a different model of programming with React. We should be aware of those “caveats” when we write custom hooks.

Let us learn by example. We will build a custom hook for setInterval. We will call useInterval. The custom hook would do pretty much similar to what setInterval would do but in a declarative way. It would take a callback and interval as arguments and call the callback at the given interval.

The function signature will look like:

1function useInterval(callback, interval) {
2 // implementation of useInterval
3}

So we want to call the callback, after the interval. And clear the interval to avoid memory leak and other potential bugs. So a very naive implementation of useInterval would look like this:

1function useInterval(callback, interval) {
2 useEffect(() => {
3 const id = setInterval(callback, interval)
4 return () => clearInterval(id)
5 }, [interval])
6}
7
8function Counter() {
9 let [count, setCount] = useState(0)
10
11 useInterval(() => {
12 setCount(count + 1)
13 }, 1000)
14 return <h1>{count}</h1>
15}

You might have already seen the problem with the implementation. Let us use this version of the hook and see for it ourself.

0


callback is missing from the dependency array, because of that the count is enclosed on 0. And because of that, we get the counter to update from 0 to 1 after every interval.

1function useInterval(callback, interval) {
2 useEffect(() => {
3 const id = setInterval(callback, interval)
4 return () => clearInterval(id)
5 }, [interval])
6}

We can fix that by adding callback to the dependency array.

1function useInterval(callback, interval) {
2 useEffect(() => {
3 const id = setInterval(callback, interval)
4 return () => clearInterval(id)
5 }, [interval, callback])
6}

0


So now that app looks like it working, so thats it?

No not really! Lets add another counter, and see how the app works.

1function Counter() {
2 let [count1, setCount1] = useState(0)
3 let [count2, setCount2] = useState(0)
4
5 useInterval(() => {
6 setCount(count1 + 1)
7 }, 1000)
8
9 useInterval(() => {
10 setCount2(count2 + 1)
11 }, 2000)
12
13 return (
14 <h1>
15 {count1}, {count2}
16 </h1>
17 )
18}

0

0


You would have noticed that our second counter is never incremented. Why would that be the case?

Lets refactor a bit, and see what is happening.

1function Counter() {
2 let [count1, setCount1] = useState(0)
3 let [count2, setCount2] = useState(0)
4
5 function handleFirstCounter() {
6 setCount1(count1 + 1)
7 }
8
9 function handleSecondCounter() {
10 setCount2(count2 + 1)
11 }
12
13 useInterval(handleFirstCounter, 1000)
14 useInterval(handleSecondCounter, 2000)
15
16 return (
17 <h1>
18 {count1}, {count2}
19 </h1>
20 )
21}

It is because of when count of our first counter changes, the Counter component is re rendered. Because of this callback is recreated.

So lets list down the what has happens:

  1. count and count2 are 0, interval for 1s and 2s is set.
  2. After the first second count1 is updated to 2 and Counter is re-rendered.
  3. Causes handleFirstCounter and handleSecondCounter to be redefined.
  4. Since useInterval has callback as dependency, it clears the old intervals. For both count1 and count2, a new interval is created.
  5. Interval for count2 is never called because interval for count1 gets called before count2 and handlers are reset.

So we need callback to hold the latest state. But we can’t list it in dependency.

Isn’t that useCallback is meant for? Let’s try wrapping handleFirstCounter and handleSecondCounter in useCallback with dependencies as [].

1function Counter() {
2 let [count1, setCount1] = useState(0)
3 let [count2, setCount2] = useState(0)
4
5 const handleFirstCounter = useCallback(() => {
6 setCount1(c => c + 1)
7 }, [])
8
9 const handleSecondCounter = useCallback(() => {
10 setCount2(c => c + 1)
11 }, [])
12
13 useInterval(handleFirstCounter, 1000)
14 useInterval(handleSecondCounter, 2000)
15
16 return (
17 <h1>
18 {count1} {count2}
19 </h1>
20 )
21}

0

0


So our app is working, but did we solve the problem? We didn’t. We just prolongated it. handleFirstCounter’s reference will change if it is using some other state.

for eg.

1const handleFirstCounter = useCallback(() => {
2 setCount1(c => c + step)
3}, [])

0

0


This would again mess up the “stability” of our function. And it would act weirdly (a count is missed when the step is changed). Hit the step button continuously you should be able see the counter paused.

So here is the solution to our problem. We are saving the callback function in ref.

1function useInterval(callback, interval) {
2 const callbackRef = useRef()
3
4 useEffect(() => {
5 callbackRef.current = callback // Update ref to the latest callback.
6 }, [callback])
7
8 // Set up the interval.
9 useEffect(() => {
10 function cb() {
11 callbackRef.current()
12 }
13 if (interval !== null) {
14 let id = setInterval(cb, interval)
15 return () => clearInterval(id)
16 }
17 }, [interval])
18}

0

0


We saw that the counter was kind of “paused” in our previous example. And in our current example, if you do the same you will find that counter would still work.

Some of you might think, we might want to pause the counter while the steps is being changed. We can achieve that with setting interval as null.

So why does this solution work?

This solution works because cb inside our useEffect will never change inside the effect. And the effect will only run when the interval changes. And we can point to the lastest function by updating the reference callbackRef.current = callback

This solution also allows us to give the freedom to the user to use useCallback or inline function, our application will work fine.

Ability to pass a function as a parameter is what makes Javascript so powerful. We would accept a function as arguments in a custom hook. If your custom hook is accepting a function as an argument and that is listed as a dependency to useEffect or as a callback let us try using useRef. It enables us to have a better API and stable hook.


Got a Question? Bala might have the answer. Get them answered on #AskBala

Edit on Github

Subscribe Now! Letters from Bala

Subscribe to my newsletter to receive letters about some interesting patterns and views in programming, frontend, Javascript, React, testing and many more. Be the first one to know when I publish a blog.

No spam, just some good stuff! Unsubscribe at any time

Written by Balavishnu V J. Follow him on Twitter to know what he is working on. Also, his opinions, thoughts and solutions in Web Dev.