The best way to pass callback functions to your custom hooks (without useCallback)

When you're creating a custom hook that accepts some callback function as a parameter, you'll very likely come across a tricky problem that many don't really know how to solve.

But there's actually a very cool (& kinda simple) pattern that you can use to solve this problem neatly!!

To really understand the solution, let's quickly understand the problem with a small example.

You want to build a custom "useTimeout" hook.

This hook should accept 2 parameters:

  • a delay amount (number)
  • a callback function

After "delay" time from the component's mount passes, the "callback" function should be invoked.

Seems easy enough, right??

Here is how a naive implementaion could look like:

const useTimeout = (callback, delay) => {

 useEffect(() => {
 const id = setTimeout(() => callback(), delay)
 return () => clearTimeout(id)
 }, [callback, delay])

}

Now let's use this custom hook in a Clicks Counter component:

const ClicksCounter = () => {
 const [cnt, setCnt] = useState(0);

 useTimeout(() => {
 alert(`You clicked ${cnt} clicks in the first 3 seconds!!`);
 }, 3000);

 return <button onClick={() => setCnt(cnt+ 1)}> + </button>;
};

I think you already spotted the problem, didn't you?

Instead of calling 'callback' after 3 seconds from the component's initial mount, each button click will cause the timeout to reset & wait another 3 seconds!!

The reason why this is happening is because "callback" is being recreated each time the count's state changes, & this callback is included in the useEffect's deps array, so it re-runs the effect.

Even worse, even if the "clicks" state didn't change, whenever the "ClicksCounter" componenet re-renders for whatever reason (e.g., the parent re-rendered), a new callback will be created & the useEffect will run again too...

We clearly need a fix here!!

The first "solution" that someone might suggest would be: Do NOT include "callback" in useEffect's deps array šŸ™ƒ

Please please don't do this... Because even if this fixes the current bug, it'll introduce SEVERAL future bugs.

A second solution is: Wrap the callback function with a useCallback hook before passing it to our custom hook.

This will make the callback identity stable & will only change when any of its deps change.

This solution doesn't have any bugs, & actually, several very popular libraries already use this approach. However, I personally don't like this solution that much...

& that's because of 2 reasons:

1- It still won't fix all the cases (like our example above. when the callback deps themselves change) 2- It's neither safe nor actually clean to use...

Why?? Because when you follow this approach, you are trusting that the users of your hook will always remember to wrap their callbacks with useCallback. But they can (& will) easily forget to do that.

So here comes the third & best solution: It's sometimes called: "The latest ref pattern".

The idea of this pattern is quite simple actually: 1- Create a ref object to store the callback ref. 2- Create a use(Layout)Effect hook that runs on each render & updates the current ref value 3- Remove the callback ref from any deps array.

Just check the code below to see what I mean:

const useTimeout = (callback, delay) => {
 const callbackRef = useRef(callback);

 useLayoutEffect(() => {
 callbackRef.current = callback;
 });

 useEffect(() => {
 const id = setTimeout(() => callbackRef.current(), delay);

 return () => clearTimeout(id);
 }, [delay]);
};

This solution checks all the boxes for me:
āœ… Works without any bugs (that I know of at least)
āœ… Guarantees stability for hooks that uses this callback (useEffect/useCallback/useMemo)
āœ… Best DX for the hook's users (they can just pass the callback directly to the hook, no need to wrap with useCallback/useMemo each time)

Awesome!! In fact, there's currently an RFC to add a new core hook to React called "useEvent" which aims to solve this same problem using this pattern. You can check the RFC page here: useEvent RFC

And that's it!!

Leave a like on the post if you found this useful šŸ‘ & have a nice day! šŸ‘‹

My Photo

About Me

I'm a freelance web developer who specializes in frontend.
I have a special love for React. And my personal goal is: Building things that are Awesome! āœØ
If you are someone who is building a project where high quality is a MUST, then Let's Chat! I'll be glad to help you with it.

Ā© 2023-present Mohammed Taher Ghazal. All Rights Reserved.