Using callbacks in custom hooks
2 mins |
November 20, 2020React 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 useInterval3}
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}78function Counter() {9 let [count, setCount] = useState(0)1011 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)45 useInterval(() => {6 setCount(count1 + 1)7 }, 1000)89 useInterval(() => {10 setCount2(count2 + 1)11 }, 2000)1213 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)45 function handleFirstCounter() {6 setCount1(count1 + 1)7 }89 function handleSecondCounter() {10 setCount2(count2 + 1)11 }1213 useInterval(handleFirstCounter, 1000)14 useInterval(handleSecondCounter, 2000)1516 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:
count
andcount2
are 0, interval for1s
and2s
is set.- After the first second
count1
is updated to2
andCounter
is re-rendered. - Causes
handleFirstCounter
andhandleSecondCounter
to be redefined. - Since
useInterval
hascallback
as dependency, it clears the old intervals. For bothcount1
andcount2
, a new interval is created. - Interval for
count2
is never called because interval forcount1
gets called beforecount2
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)45 const handleFirstCounter = useCallback(() => {6 setCount1(c => c + 1)7 }, [])89 const handleSecondCounter = useCallback(() => {10 setCount2(c => c + 1)11 }, [])1213 useInterval(handleFirstCounter, 1000)14 useInterval(handleSecondCounter, 2000)1516 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()34 useEffect(() => {5 callbackRef.current = callback // Update ref to the latest callback.6 }, [callback])78 // 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
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