A custom React hook for watching media query changes
A few weeks ago, I wrote about how to watch for media query changes with JavaScript.
Today, I wanted to share a little custom React hook you can use to detect media changes and automatically trigger a new render when it happens.
Let’s dig in!
An example
Let’s imagine you have a component with a video animation.
function AnimatedChart ({ src }) {
return (
<video
autoPlay={true}
muted={true}
loop={true}
src={src}
>
);
}If the user has prefers-reduced-motion enabled, you want to show a static image instead.
And, if they change their setting once the component is already in the DOM, you want to update it (either stopping the video, or adding it back in, accordingly).
Creating a custom React hook
We’ll start by importing useEffect() and useState(), as we’ll need both of them.
We’ll also create our hook, useWatchMatchMedia(), and export it. The hook accepts the match media selector as an argument.
import { useEffect, useState } from 'react';
/**
* A hook to checks for matchMedia() settings and react to changes
* @param {string} selector The match media selector string
* @return {state} The state object
*/
export function useWatchMatchMedia (selector) {
// Code will go here...
}Inside our hook, we’ll set an initial state of false, and assign the state and setter function to the matches and setMatches variables, respectively.
We’ll return the matches state back out as an object property.
/**
* A hook to checks for matchMedia() settings and react to changes
* @param {string} selector The match media selector string
* @return {state} The state object
*/
export function useWatchMatchMedia (selector) {
// If true, user prefers reduced motion
const [matches, setMatches] = useState(false);
return { matches };
}Setting matches based on media query
Next, we’ll use useEffect() to check for the actual media query value when the component is rendered. We’ll pass in the selector as a dependency.
/**
* A hook to checks for matchMedia() settings and react to changes
* @param {string} selector The match media selector string
* @return {state} The state object
*/
export function useWatchMatchMedia (selector) {
// If true, user prefers reduced motion
const [matches, setMatches] = useState(false);
// On render, sets user preference and listens for changes
useEffect(() => {
// ...
}, [selector]);
return { matches };
}We do this in useEffect() instead of when setting our initial state because in some setups, the window object used for the matchMedia() method isn’t available when the component first loads.
Inside the useEffect() callback function, we’ll pass our selector into window.matchMedia(), get the returned MatchMedia object, and pass its .matches property into setMatches() to set the current state.
// On render, sets user preference and listens for changes
useEffect(() => {
const prefersReducedMotion = window.matchMedia(selector);
setMatches(prefersReducedMotion.matches);
}, [selector]);Listening for query changes
We also want to update our matches state when the user updates their settings or the query value changes.
We’ll create an updateMatches() method that will run whenever there’s a change to the MatchMedia object, and updates the matches state. Then we’ll listen for change events on the object, and pass the function in as a callback.
We’ll also return a function that removes the event listener when the component is removed, so we don’t have unnecessary event listeners cluttering up browser memory.
// On render, sets user preference and listens for changes
useEffect(() => {
const prefersReducedMotion = window.matchMedia(selector);
setMatches(prefersReducedMotion.matches);
// Callback function for updating user preference
function updateMatches (event) {
setMatches(event.matches);
}
prefersReducedMotion.addEventListener('change', updateMatches);
return () => {
prefersReducedMotion.removeEventListener('change', updateMatches);
};
}, [selector]);Using the useWatchMatchMedia() hook in our component
Back in our <AnimatedChart /> component, we’ll import the new useWatchMatchMedia() hook.
Then we’ll run it, passing in (prefers-reduced-motion) as the selector. We’ll assign the returned matches variable to a variable, and rename it prefersReducedMotion for clarity.
function AnimatedChart ({ src }) {
const { prefersReducedMotion } = useWatchMatchMedia('(prefers-reduced-motion)');
return (
<video
autoPlay={true}
muted={true}
loop={true}
src={src}
>
);
}Now, we can use the prefersReducedMotion state to conditionally render a video or image. We’ll add a fallback property to our component to use.
function AnimatedChart ({
src,
fallback
}) {
const { prefersReducedMotion } = useWatchMatchMedia('(prefers-reduced-motion)');
if (prefersReducedMotion) {
return (
<img
alt={fallback.alt}
src={fallback.src}
/>
)
}
return (
<video
autoPlay={true}
muted={true}
loop={true}
src={src}
>
);
}Now, if the user has prefers-reduced-motion enabled, or toggles it on after the component is rendered, they’ll get the fallback image. Otherwise, they’ll get the video.