And here's the useTimeout
hook — I'll skip the part where Dan explains why this code looks what it looks like, please check out his original post if you have any questions. I only replaced the interval with a timeout, because, to tell you the truth, I have used intervals exactly zero times in the past 5 years, but I use timeouts every week.
function useTimeout(callback, delay) {
const savedCallback = useRef();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
if (delay != null) {
const id = setTimeout(() => {
savedCallback.current();
}, delay);
return () => clearTimeout(id);
}
}, [delay]);
}
It's a nice, consistent hook that does many things right — in fact, it's similar to my idea of the perfect useCallback. Let's first admire the things it does right:
But then there's something I don't like that much. To set a callback, we switch the hovered
state. This state change triggers the effect in useTimeout
that actually sets the timeout. But, like every state change, it also happens to re-render a component. So, while we're calling our setTimeout
, we also get to:
hovered
has changed, and schedule that effect from useTimeout
setTimeout
!I mean, it will all probably happen pretty fast, but come on, is calling a setTimeout
really worth all that fuss? Me, I don't think so. The idea of making my user's CPU go through all that hoops to call a function makes me very sad. Luckily, I know how to fix it.
What if we were to skip the declarative part, and just tried to build a consistent hook wrapper around setTimeout
? Here's my take (we use a very similar hook in our production code):
function useImperativeTimeout(callback, delay) {
const timeoutId = useRef(null);
const savedCallback = useRef();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// this handle clears the timeout
const clear = useCallback(() => {
clearTimeout(timeoutId.current);
}, []);
// this handle sets our timeout
const set = useCallback(() => {
// but clears the old one first
clear();
timeoutId.current = setTimeout(() => {
savedCallback.current();
}, delay);
}, [delay]);
// also, clear the timeout on unmount
useEffect(() => clear, []);
return { set, clear };
}
We can finally call timeout.set()
and just have it setTimeout
for us and do nothing else. I've left the original savedCallback
logic intact, nothing wrong with it.
The hook behavior in some corner cases has changed. If I set the timeout to 300ms, and then 200ms later change the delay to 50ms, should it fire in 300 – 200 = 100ms, as originally intended (my behavior)? 50ms from now (Dan's behavior)? 50 – 200 = 150ms ago (haha, that's very correct but you can't do that)? RIGHT NOW if we're already past deadline? Who knows. All options are fine for such a weird case as long as it doesn't explode.
But now our Input
has to wrangle with the nasty imperatives, and it probably looks awful. Not at all:
const Input = ({ details }) => {
const [showDetails, setShowDetails] = useState(false);
const showTimeout = useImperativeTimeout(() => {
setShowDetails(true);
}, 100);
const onEnter = showTimeout.set;
const onLeave = () => {
showTimeout.clear();
setShowDeatils(false);
};
return (
<div>
<input />
<span
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>i</span>
</div>
);
};
In fact, we've not only eliminated the extra render, but also removed the hovered
state whose only job was to toggle the timeout. I'd say good old imperatives just scored a goal.
Upon closer inspection, our initial "declarative" useTimeout
is not that declarative. Take note:
onMouseOver
event handler is imperative,setHovered
is imperative — even grammatically, I sometimes say "come on React, set hovered to true",setTimeout
is imperative, too.We're basically converting these imperative things into the declarative world, then back again.
Moreover, the mental model is slightly broken — while hovered
flag supposedly means "timeout is running", it may not be the case. The timeout is either running or has already fired. But maybe that's just me being tedious.
Now suppose I want to implement a debounce with the declarative useTimeout. I want to track my user's mouse motion, and show a popup once he stops moving. For that, I normally set a small timeout to show the popup — 30ms will do — on mousemove
. If the user moves the mouse again within the next 30ms, well, I set another timeout and try again. If the mouse stops, the timeout successfully fires, and the popup appears. Really simple (no React yet):
let popupTimeout = null;
img.addEventListener('mousemove', () => {
clearTimeout(popupTimeout);
popupTimeout = setTimeout(showPopup, 30);
});
But the only way to set our decalrative useTimeout is passing a non-null delay. How would you do this with our declarative timeout?
function Img({ title, ...props }) {
const [hasPopup, setPopup] = useState(false);
useTimeout(() => setPopup(true), ??);
const onMove = ??
return <>
<img onMouseMove={onMove} {...props} />
{hasPopup && <div>{title}</div>}
</>;
}
You could move the delay a little bit, like 30 -> 31 -> 30, or dance around with 30 -> null -> 30, but that's just dirty. In any case, mousemove
is absolutely not the event you'd want to re-render on.
Imperative timeout to the rescue:
function Img({ title, ...props }) {
const [hasPopup, setPopup] = useState(false);
const popupTimeout = useImperativeTimeout(
() => setPopup(true),
30);
const onMove = popupTimeout.set;
return <>
<img onMouseMove={onMove} {...props} />
{hasPopup && <div>{title}</div>}
</>;
}
It works, it's fast, it's simple. 2:0 in favor of old school!
Before you point this out to me, I'd love to quote the original article's disclaimer myself: This post focuses on a pathological case. Even if an API simplifies a hundred use cases, the discussion will always focus on the one that got harder. I'll be the first to admit I'm now exploring a pathological case of a pathological case. Know why? Because that's the kind of stuff I enjoy.
Problem is, the fully declarative API most hooks offer is on a higher level of abstraction than imperative handles. JS culture of making lower-lever building blocks inaccessible to the library users has bothered me for a long time (ouch, I still remember that time I copy-pasted react-router source to modify link actions for an electron app). But I think this culture has probably peaked in hooks.
Declarative timeout is very convenient in many cases:
mousedown
, but also a keydown
— separating cause and effect with an intermediate state works great.But, as we've seen, it makes some other cases impossibly difficult, and can introduce wasted renders.
What if we could have the best of both worlds — provide a nice declarative API for 90% use cases, and also an imperative one to please old grumpy people like me? Yes we can:
function useWrapTimeout(callback, delay) {
const handle = useImperativeTimeout(callback, delay);
useEffect(() => {
if (delay != null) {
handle.set();
return handle.clear;
}
}, [delay]);
}
This is what you think it is — the declarative timeout, built on top of our imperative timeout. Works absolutely the same. We could even expose both APIs from a single hook (just return handle
), but the interaction between the declarative state and imperative overrides is not pleasant. On the other hand, declarative timeout can't be used to build an imperative timeout, period.
A traditional recap:
Dear library authors, please do expose lower-level APIs. Don't make me copy-paste your code to do things a little differently from the 95% use case.
Want to learn more about pathological cases in React hooks? I have a lot of that. See you around!