Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save bvaughn/fc1c3f27f1aab91c7378f2264f7c3aa1 to your computer and use it in GitHub Desktop.
Save bvaughn/fc1c3f27f1aab91c7378f2264f7c3aa1 to your computer and use it in GitHub Desktop.
Attaching manual event listeners in a passive effect
// Simplistic (probably most common) approach.
//
// This approach assumes either that:
// 1) passive effects are always run asynchronously, after paint, or
// 2) passive effects never attach handlers for bubbling events
//
// If both of the above are wrong (as can be the case) then problems might occur!
useEffect(() => {
const handleDocumentClick = (event: MouseEvent) => {
// It's possible that a "click" event rendered the component with this effect,
// in which case this event handler might be called for the same event (as it bubbles).
// In most scenarios, this is not desirable.
// ...
};
const ownerDocument = ((modalRef.current: any): HTMLDivElement).ownerDocument;
ownerDocument.addEventListener("click", handleDocumentClick);
return () => {
ownerDocument.removeEventListener("click", handleDocumentClick);
};
}, []);
// There are 3 alternatives.
// I've listed them in order of my preference (most to least).
// Alternate design 1: Event time
// This uses the event.timeStamp field to avoid reacting to events that were dispatched before the effect.
// Note that the "timeStamp" property does not exist for all event types.
// Also note that for older browsers (e.g. Chrome 49 ~ 2016) this field is millisecond vs microseconds precision.
// My thoughts are that React is unlikely to ever render+commit+flush effects in <1ms so this shouldn't matter.
useEffect(() => {
const timeOfEffect = performance.now();
const handleDocumentClick = (event: MouseEvent) => {
if (timeOfEffect > event.timeStamp) {
// Ignore events that were fired before the effect ran,
// in case this effect is being run while an event is currently bubbling.
// In that case, we don't want to react to a pre-existing event.
return;
}
// ...
};
const ownerDocument = ((modalRef.current: any): HTMLDivElement).ownerDocument;
ownerDocument.addEventListener("click", handleDocumentClick);
return () => {
ownerDocument.removeEventListener("click", handleDocumentClick);
};
}, []);
// Alternate design 2: setTimeout
// This approach uses setTimeout to delay adding the handler until any current event has finished bubbling.
// This requires extra conditional cleanup logic to avoid leaking.
useEffect(() => {
const handleDocumentClick = (event: MouseEvent) => {
// ...
};
let ownerDocument = null;
let timeoutID = setTimeout(() => {
timeoutID = null;
ownerDocument = ((modalRef.current: any): HTMLDivElement).ownerDocument;
ownerDocument.addEventListener("click", handleDocumentClick);
}, 0);
return () => {
if (timeoutID !== null) {
// Don't attach handlers if we're unmounted before the timeout has run.
// This is important! Without it, we might leak!
clearTimeout(timeoutID);
}
if (ownerDocument !== null) {
ownerDocument.removeEventListener("click", handleDocumentClick);
}
};
}, []);
// Alternate design 3: Microtasks
// This approach uses queueMicrotask to delay adding the handler until any current event has finished bubbling.
// This requires extra conditional logic to avoid running code after unmount.
// It also requires a polyfill check _and_ extra ref logic to handle Offscreen hide/show.
const scheduleMicrotask =
typeof queueMicrotask === "function"
? queueMicrotask
: typeof Promise !== "undefined"
? (callback) =>
Promise.resolve(null)
.then(callback)
.catch((error) => {
setTimeout(() => {
throw error;
});
})
: setTimeout;
const isHiddenRef = useRef(false);
useEffect(() => {
// Reset this in case we've been hidden and shown again (via Offscreen API).
isHiddenRef.current = false;
const handleDocumentClick = (event: MouseEvent) => {
// ...
};
let ownerDocument = null;
scheduleMicrotask(() => {
if (isHiddenRef.current === true) {
// Can't cancel a microtask;
// But don't add a handler if the effect has already been destroyed, or we'd leak!
return;
}
ownerDocument = ((modalRef.current: any): HTMLDivElement).ownerDocument;
ownerDocument.addEventListener("click", handleDocumentClick);
}, 0);
return () => {
isHiddenRef.current = true;
if (ownerDocument !== null) {
ownerDocument.removeEventListener("click", handleDocumentClick);
}
};
}, []);
@sebmarkbage
Copy link

sebmarkbage commented Jan 13, 2022

The third approach doesn’t work because micro-tasks fire between bubbles events. You can use postTask instead though.

Another approach could be to use window.event.

useEffect(() => {
  const mountedEvent = window.event;
  const handleClick = event => {
    if (mountedEvent !== event) {
      handleDocumentClick(event);
    }
  };
  document.addEventListener(“click”, handleClick);
  return () => {
    document.removeEventListener(“click”, handleClick);
  };
});

This doesn’t work if the event is triggered inside a different synchronous event inside a click event though.

I believe that in practice this doesn’t come up a lot because you typically want to listen to the capture event anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment