← home

JS media queries with React hooks

This post assumes you’re using React (16.8 or later).

One thing I like about styled-components is that it enables concise and declarative media queries (granted, regular media queries are already declarative[^1]). On this site, I use a file that exports the following mediaQueries object.

const min = width => `only screen and (min-width: ${width}em)`
const max = width => `only screen and (max-width: ${width}em)`

// screen sizes in em units
export const screens = {
  phone: 30,
  phablet: 40,
  tablet: 50,
  netbook: 60,
  laptop: 70,
  desktop: 100,
}

export const mediaQueries = Object.entries(screens).reduce((acc, [key, val]) => {
  const Key = key[0].toUpperCase() + key.substr(1)
  acc[`min` + Key] = `@media ` + min(val)
  acc[`max` + Key] = `@media ` + max(val)
  return acc
}, {})

To get consistent media queries across a project, you simply import that object wherever you need a media query:

import styled from 'styled-components'

import { mediaQueries } from 'src/utils/mediaQueries.js'

const { minPhone, maxPhone } = mediaQueries

export default styled.div`
  ${maxPhone} {
    // some styles to apply only on phones
  }
  ${minPhone} {
    // some styles to apply on screens larger than phones
  }
`

However, sometimes CSS alone doesn’t cut it. Thankfully, window.matchMedia makes it very easy to use media queries directly in JavaScript without any 3rd party dependencies! And it even has great browser support.

window.matchMedia browser support window.matchMedia browser support

window.matchMedia in React

Here’s how you’d use it in React:

import React, { useState, useEffect } from 'react'

import { Mobile, Desktop } from 'src/components'

const maxPhone = `only screen and (max-width: 30em)` // highlight-line

export default function ResponsiveComponent(props) {
  // highlight-start
  const query = window.matchMedia(maxPhone)
  const [match, setMatch] = useState(query.matches)
  // highlight-end

  useEffect(() => {
    const handleMatch = (q) => setMatch(q.matches)
    query.addListener(handleMatch)
    return () => query.removeListener(handleMatch)
  })

  return match ? <Mobile {...props} /> : <Desktop {...props} /> // highlight-line
}

Note that JS media queries like maxPhone need to omit the @media prefix present in CSS media queries. window.matchMedia(maxPhone) then turns that string into a query object which becomes the JavaScript equivalent of @media screen and (max-width: 30em). We call useState to manage whether or not the query currently matches the screen size, followed by useEffect which creates an event listener that updates the query status on window resizes. Finally, we return the Mobile or Desktop implementation of ResponsiveComponent, depending on the state of the query.

If you’re server-side rendering (SSR), you’ll need to wrap this code in a check that ensures the window object is defined.

import React, { useState, useEffect } from 'react'

const maxPhone = `screen and (max-width: 30em)`

export default function ResponsiveComponent(props) {
  // highlight-next-line
  if (typeof window !== `undefined`) {
    const query = window.matchMedia(maxPhone)
    const [match, setMatch] = useState(query.matches)

    useEffect(() => {
      const handleMatch = (q) => setMatch(q.matches)
      query.addListener(handleMatch)
      return () => query.removeListener(handleMatch)
    })

    return match ? <Mobile {...props} /> : <Desktop {...props} />
  } else return null // highlight-line
}

Hook it up

Since this functionality will likely be reused many times, it makes sense to turn it into a custom hook. Let’s call it useMediaQuery.

import { useEffect, useState } from 'react'

export const useMediaQuery = (query, cb) => {
  const [matches, setMatches] = useState(false)

  useEffect(() => {
    const qry = window.matchMedia(query)
    setMatches(qry.matches)

    const handleMatch = q => {
      setMatches(q.matches)
      if (cb instanceof Function) cb(q.matches)
    }

    qry.addListener(handleMatch)
    return () => qry.removeListener(handleMatch)
  }, [query, cb])

  return matches
}

useMediaQuery is more versatile than just checking for screen sizes. For instance, you could also pass it the string prefers-color-scheme: (light|dark) to check if the user has his device set to a dark or light color mode and adjust the style of your site accordingly.

This hook accepts a callback function as second argument. cb will be triggered by useMediaQuery whenever the state of the media query changes. Continuing our dark mode example, this might be useful if you have a function that sets the colors of your site and that needs to be triggered when a user is browsing your site in the evening while it gets dark and the device automatically switches from light to dark mode.

Back to screen sizes. One last thing to mention is if you find yourself using JS media queries a lot, it might become annoying to have to type the whole query string (maxPhone in the above example) every time. In that case, it would easier (and help with consistency) to create a second hook that’s coupled to the mediaQueries object. To that end, you’ll need to modify src/utils/mediaQueries.js to contain each media query both in its CSS and JS variant, i.e. with and without the @media prefix.

const min = width => `only screen and (min-width: ${width}em)`
const max = width => `only screen and (max-width: ${width}em)`

// screen sizes in em units
export const screens = {
  phone: 30,
  phablet: 40,
  tablet: 50,
  netbook: 60,
  laptop: 70,
  desktop: 100,
}

export const mediaQueries = Object.entries(screens).reduce((acc, [key, val]) => {
  const Key = key[0].toUpperCase() + key.substr(1)
  // css query
  acc[`min` + Key] = `@media ` + min(val)
  acc[`max` + Key] = `@media ` + max(val)
  // highlight-start
  // js query (see window.matchMedia)
  acc[`min` + Key + `Js`] = min(val)
  acc[`max` + Key + `Js`] = max(val)
  // highlight-end
  return acc
}, {})

And then we create a wrapper for useMediaQuery. Let’s call it useScreenQuery.

import { useMediaQuery } from 'hooks/mediaQueries'
import { mediaQueries } from 'utils/mediaQueries'

const validKeys = Object.keys(mediaQueries).filter(key => !key.includes(`Js`))

export const useScreenQuery = (key, cb) => {
  if (!mediaQueries[key + `Js`])
    throw new TypeError(
      `useScreenQuery received invalid key: ${key}. Should be one of ${validKeys}`
    )
  return useMediaQuery(mediaQueries[key + `Js`], cb)
}

As an example, here’s how you might call useScreenQuery to switch between a MobileNav and a DesktopNav.

import React from 'react'
import { graphql, useStaticQuery } from 'gatsby'
import { useScreenQuery } from 'hooks'
import { DesktopNav, MobileNav } from 'components'

export default function Nav(props) {
  // useScreenQuery returns true or false on client, undefined in SSR.
  const mobile = useScreenQuery(`maxPhablet`) // highlight-line
  if (mobile) return <MobileNav {...nav} {...props} />
  // Only render DesktopNav if screen query is false.
  if (mobile === false) return <DesktopNav {...nav} {...props} /> // highlight-line
  // Render nothing in SSR to avoid showing DesktopNav on mobile
  // on initial page load from cleared cache.
  return null // highlight-line
}

For a more elaborate example involving a media query with multiple break points, check out the useMedia post on usehooks.com.

[^1]: solve problems without requiring the programmer to specify an exact procedure to be followed