DEV Community

Evert Bouw
Evert Bouw

Posted on

Let's make a reusable React hook!

I'm going to assume you've read the official hooks documentation. If not please do!

Let's start with a simple component:

const Foo = () => {
    return <div>Hello world</div>;
};

I want this component to do something based on a media query. This can be done with window.matchMedia. For this tutorial I'll use the media query (prefers-color-scheme: dark). Let's add this to the component:

const Foo = () => {
    const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
    // btw this query currently only works in cool browsers,
    // but dark mode is for cool people only so that's fine

    return <div>Hello {mediaQueryList.matches ? "dark" : "light"} world</div>;
};

Cool, that works! But we don't really need to run the query every time this component renders. Let's add the first hook, useMemo.

const Foo = () => {
    const mediaQueryList = useMemo(
        () => window.matchMedia("(prefers-color-scheme: dark)"),
        [],
    );

    return <div>Hello {mediaQueryList.matches ? "dark" : "light"} world</div>;
};

Great. But media queries can change. You can rotate your phone or make the browser screen smaller, or in our case you can switch between light and dark mode in your operating system.

So let's add an event listener. That listener will need to set some state so the component rerenders with the new value.

const Foo = () => {
    const mediaQueryList = useMemo(
        () => window.matchMedia("(prefers-color-scheme: dark)"),
        [],
    );

    const [matches, setMatches] = useState(mediaQueryList.matches);

    useEffect(() => {
        const listener = event => {
            setMatches(event.matches);
        };

        mediaQueryList.addEventListener("change", listener);

        return () => {
            mediaQueryList.removeEventListener("change", listener);
        };
    }, []);

    return <div>Hello {matches ? "dark" : "light"} world</div>;
};

// Ryan Florence might call this 90% cleaner code,
// but this tutorial is only half way done

And that's it! We don't need to add anything else. But can we reuse anything? Getting the matches property from the event and from the mediaQueryList feels like duplication, let's create a function for that. Then let's move all the wiring to a custom hook that takes the query as an argument.

const getMatches = mediaQueryList => mediaQueryList.matches;

const useMediaQuery = query => {
    const mediaQueryList = useMemo(
        () => window.matchMedia(query),
        // Let's recreate the media query list when the query changes.
        // Might be useful
        [query],
    );

    const [matches, setMatches] = useState(getMatches(mediaQueryList));

    useEffect(
        () => {
            const listener = event => {
                setMatches(getMatches(event));
            };

            mediaQueryList.addEventListener("change", listener);

            return () => {
                mediaQueryList.removeEventListener("change", listener);
            };
        },
        // if the mediaQueryList can change we'll also need to resubscribe
        // to get the correct updates
        [mediaQueryList],
    );

    // the component only cares about `matches`, so let's return it
    return matches;
};

const Foo = () => {
    const matches = useMediaQuery("(prefers-color-scheme: dark)");

    return <div>Hello {matches ? "dark" : "light"} world</div>;
};

That's nice we've created a reusable media query hook. But we can go deeper. If we move listener out of useEffect we can move useEffect to its own hook. That hook takes an object, event name, and callback function as arguments.

// I can never remember the order of so many properties,
// so I put them in an object
const useEventListener = ({ eventName, listener, element }) => {
    useEffect(
        () => {
            element.addEventListener(eventName, listener);

            return () => {
                element.removeEventListener(eventName, listener);
            };
        },
        // We'll rerun the effect when any of the arguments change
        [eventName, listener, element],
    );
};

const getMatches = mediaQueryList => mediaQueryList.matches;

const useMediaQuery = query => {
    const mediaQueryList = useMemo(() => window.matchMedia(query), [query]);

    const [matches, setMatches] = useState(getMatches(mediaQueryList));

    const listener = useCallback(event => {
        // This listener is now created outside of the useEffect hook.
        // Since we are resubscribing every time this function changes
        // we'll need to useCallback
        setMatches(getMatches(event));
    }, []);

    useEventListener({
        eventName: "change",
        element: mediaQueryList,
        listener,
    });

    return matches;
};

That useEventListener hook looks very useful already. But I can see myself passing window to it the most so I'll make that the default. Also I might not need to have the listener be active all the time, but you can't put hooks in a condition. So let's add a condition inside of the hook.

const useEventListener = ({
    eventName,
    listener,
    element = window,
    active = true,
}) => {
    useEffect(() => {
        if (active) {
            // sneaky fix for Edge that doesn't seem to support addEventListener in mediaQueryList
            if ("addListener" in element) {
                element.addListener(listener);

                return () => {
                    element.removeListener(listener);
                };
            }

            element.addEventListener(eventName, listener);

            return () => {
                element.removeEventListener(eventName, listener);
            };
        }
    }, [eventName, listener, element, active]);
};

We've now got a perfectly generic useEventListener hook, you never have to write addEventListener again. Don't believe me? Let's reuse it right now. I like to be annoying so let's use this hook to prevent people from leaving my app.

const listener = event => {
    event.preventDefault();
    event.returnValue = "";
};

const usePrompt = active =>
    useEventListener({
        eventName: "beforeunload",
        listener,
        active,
    });

// I regret nothing.

I think we can go back to our media query hook and split that up once more. Capturing event values is something I want to use outside of media queries as well.

const useEvent = ({
    eventName,
    getValue,
    initialState,
    element = window,
    active = true,
}) => {
    const [value, setValue] = useState(initialState);

    const listener = useCallback(
        event => {
            setValue(getValue(event));
        },
        [getValue],
    );

    useEventListener({
        eventName,
        listener,
        element,
        active,
    });

    return value;
};

const useMediaQuery = query => {
    const mediaQueryList = useMemo(() => window.matchMedia(query), [query]);

    return useEvent({
        eventName: "change",
        element: mediaQueryList,
        getValue: getMatches,
        initialState: getMatches(mediaQueryList),
    });
};

And this is how we can reuse it for a mouse position listener:

const getMousePosition = ({ clientX, clientY }) => [clientX, clientY];

const useMousePos = () =>
    useEvent({
        eventName: "mousemove",
        getValue: getMousePosition,
        initialState: [0, 0],
    });

const Mouse = () => {
    const [x, y] = useMousePos();

    return (
        <div>
            Your mouse is at {x},{y}
        </div>
    );
};

Top comments (1)

Collapse
 
dance2die profile image
Sung M. Kim

Gotta love the composability of hooks 🎣