From 2fb0182e1619a9af90e79d62ee667a6f286d86c0 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 17 Apr 2022 11:00:28 -0700 Subject: [PATCH] tom/use-event-listener --- app/components/Header.tsx | 19 ++++++------ app/editor/components/FloatingToolbar.tsx | 27 ++++++---------- app/hooks/useEventListener.ts | 38 +++++++++++++++++++++++ app/hooks/useMousePosition.ts | 17 +++++----- app/hooks/usePageVisibility.ts | 13 ++++---- app/hooks/usePersistedState.ts | 16 ++++------ app/hooks/useWindowSize.ts | 37 +++++++++++++--------- 7 files changed, 99 insertions(+), 68 deletions(-) create mode 100644 app/hooks/useEventListener.ts diff --git a/app/components/Header.tsx b/app/components/Header.tsx index a783675d5..775813e68 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -9,6 +9,7 @@ import { depths } from "@shared/styles"; import Button from "~/components/Button"; import Fade from "~/components/Fade"; import Flex from "~/components/Flex"; +import useEventListener from "~/hooks/useEventListener"; import useMobile from "~/hooks/useMobile"; import useStores from "~/hooks/useStores"; import { supportsPassiveListener } from "~/utils/browser"; @@ -29,19 +30,17 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) { const passThrough = !actions && !breadcrumb && !title; const [isScrolled, setScrolled] = React.useState(false); - const handleScroll = React.useCallback( - throttle(() => setScrolled(window.scrollY > 75), 50), + const handleScroll = React.useMemo( + () => throttle(() => setScrolled(window.scrollY > 75), 50), [] ); - React.useEffect(() => { - window.addEventListener( - "scroll", - handleScroll, - supportsPassiveListener ? { passive: true } : false - ); - return () => window.removeEventListener("scroll", handleScroll); - }, [handleScroll]); + useEventListener( + "scroll", + handleScroll, + window, + supportsPassiveListener ? { passive: true } : { capture: false } + ); const handleClickTitle = React.useCallback(() => { window.scrollTo({ diff --git a/app/editor/components/FloatingToolbar.tsx b/app/editor/components/FloatingToolbar.tsx index e870d4b99..c72407e90 100644 --- a/app/editor/components/FloatingToolbar.tsx +++ b/app/editor/components/FloatingToolbar.tsx @@ -6,6 +6,7 @@ import { Portal } from "react-portal"; import styled from "styled-components"; import { depths } from "@shared/styles"; import useComponentSize from "~/hooks/useComponentSize"; +import useEventListener from "~/hooks/useEventListener"; import useMediaQuery from "~/hooks/useMediaQuery"; import useViewportHeight from "~/hooks/useViewportHeight"; @@ -164,25 +165,15 @@ const FloatingToolbar = React.forwardRef( props, }); - React.useEffect(() => { - const handleMouseDown = () => { - if (!props.active) { - setSelectingText(true); - } - }; + useEventListener("mouseup", () => { + setSelectingText(false); + }); - const handleMouseUp = () => { - setSelectingText(false); - }; - - window.addEventListener("mousedown", handleMouseDown); - window.addEventListener("mouseup", handleMouseUp); - - return () => { - window.removeEventListener("mousedown", handleMouseDown); - window.removeEventListener("mouseup", handleMouseUp); - }; - }, [props.active]); + useEventListener("mousedown", () => { + if (!props.active) { + setSelectingText(true); + } + }); return ( diff --git a/app/hooks/useEventListener.ts b/app/hooks/useEventListener.ts new file mode 100644 index 000000000..37627f6e6 --- /dev/null +++ b/app/hooks/useEventListener.ts @@ -0,0 +1,38 @@ +import * as React from "react"; + +/** + * Helper to remove plumbing involved with adding and removing an event listener + * in components. + * + * @param eventName The name of the event to listen to. + * @param handler The handler to call when the event is triggered. + * @param element The element to attach the event listener to. + * @param options The options to pass to the event listener. + */ +export default function useEventListener( + eventName: string, + handler: T, + element: Window | Node = window, + options: AddEventListenerOptions = {} +) { + const savedHandler = React.useRef(); + const { capture, passive, once } = options; + + React.useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + React.useEffect(() => { + const isSupported = element && element.addEventListener; + if (!isSupported) { + return; + } + + const eventListener: EventListener = (event) => + savedHandler.current?.(event); + + const opts = { capture, passive, once }; + element.addEventListener(eventName, eventListener, opts); + return () => element.removeEventListener(eventName, eventListener, opts); + }, [eventName, element, capture, passive, once]); +} diff --git a/app/hooks/useMousePosition.ts b/app/hooks/useMousePosition.ts index 7a529ce8f..6ec4936d3 100644 --- a/app/hooks/useMousePosition.ts +++ b/app/hooks/useMousePosition.ts @@ -1,5 +1,6 @@ import { throttle } from "lodash"; import * as React from "react"; +import useEventListener from "./useEventListener"; /** * Mouse position as a tuple of [x, y] @@ -17,15 +18,15 @@ export const useMousePosition = () => { 0, ]); - const updateMousePosition = throttle((ev: MouseEvent) => { - setMousePosition([ev.clientX, ev.clientY]); - }, 200); + const updateMousePosition = React.useMemo( + () => + throttle((ev: MouseEvent) => { + setMousePosition([ev.clientX, ev.clientY]); + }, 200), + [] + ); - React.useEffect(() => { - window.addEventListener("mousemove", updateMousePosition); - - return () => window.removeEventListener("mousemove", updateMousePosition); - }, []); + useEventListener("mousemove", updateMousePosition); return mousePosition; }; diff --git a/app/hooks/usePageVisibility.ts b/app/hooks/usePageVisibility.ts index 1c5db9fef..48d5fe396 100644 --- a/app/hooks/usePageVisibility.ts +++ b/app/hooks/usePageVisibility.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import useEventListener from "./useEventListener"; /** * Hook to return page visibility state. @@ -8,13 +9,11 @@ import * as React from "react"; export default function usePageVisibility(): boolean { const [visible, setVisible] = React.useState(true); - React.useEffect(() => { - const handleVisibilityChange = () => setVisible(!document.hidden); + useEventListener( + "visibilitychange", + () => setVisible(!document.hidden), + document + ); - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, []); return visible; } diff --git a/app/hooks/usePersistedState.ts b/app/hooks/usePersistedState.ts index 42e0a51e4..1087a715f 100644 --- a/app/hooks/usePersistedState.ts +++ b/app/hooks/usePersistedState.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { Primitive } from "utility-types"; import Storage from "~/utils/Storage"; +import useEventListener from "./useEventListener"; /** * A hook with the same API as `useState` that persists its value locally and @@ -36,16 +37,11 @@ export default function usePersistedState( }; // Listen to the key changing in other tabs so we can keep UI in sync - React.useEffect(() => { - const updateValue = (event: any) => { - if (event.key === key && event.newValue) { - setStoredValue(JSON.parse(event.newValue)); - } - }; - - window.addEventListener("storage", updateValue); - return () => window.removeEventListener("storage", updateValue); - }, [key]); + useEventListener("storage", (event: StorageEvent) => { + if (event.key === key && event.newValue) { + setStoredValue(JSON.parse(event.newValue)); + } + }); return [storedValue, setValue]; } diff --git a/app/hooks/useWindowSize.ts b/app/hooks/useWindowSize.ts index f6dc36211..08c7846eb 100644 --- a/app/hooks/useWindowSize.ts +++ b/app/hooks/useWindowSize.ts @@ -1,28 +1,35 @@ import { debounce } from "lodash"; import * as React from "react"; +import useEventListener from "./useEventListener"; +/** + * A debounced hook that listens to the window resize event and returns the + * size of the current window. + * + * @returns An object containing width and height of the current window + */ export default function useWindowSize() { const [windowSize, setWindowSize] = React.useState({ width: window.innerWidth, height: window.innerHeight, }); - React.useEffect(() => { - // Handler to call on window resize - const handleResize = debounce(() => { - // Set window width/height to state - setWindowSize({ - width: window.innerWidth, - height: window.innerHeight, - }); - }, 100); + const handleResize = React.useMemo( + () => + debounce(() => { + // Set window width/height to state + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }, 100), + [] + ); - // Add event listener - window.addEventListener("resize", handleResize); + useEventListener("resize", handleResize); + + // Call handler right away so state gets updated with initial window size + handleResize(); - // Call handler right away so state gets updated with initial window size - handleResize(); - return () => window.removeEventListener("resize", handleResize); - }, []); return windowSize; }