メディアクエリを扱うReact Hooksのカスタムフックを作った話

React Hooksでぼちぼちコードを書いているのだけれども、今日カスタムフックの実装をしてようやくReact Hooksの勘所が少し理解できたような気がするので、忘れないうちにメモ代わりに書いています。

今実装しているコードで、画面幅に合わせて異なるReact Componentを返したいケースがあって、最初は画面幅をwindow.innerWidthで取得して場合分けするコードをcomponentDidMountに記述して実現していましたが、ロード後に画面サイズを変更した場合にそれが反映されない問題がありました。 基本的にはデバイス毎に異なるコンポーネントをrenderしたり、propsの値を変えたいだけなので、それほど大きな問題にはなっていませんが、 出来ればなんとかして動的な画面サイズの変更に合わせて切り替えられるようにしたいなあと思っていました。

その実現のためにuseMediaQueryというカスタムフックを実装してみました。 引数には区別したいメディアクエリの配列を渡し、返り値にその中で現在のメディアクエリを返すようになっています。 画面幅の変更等でマッチするメディアクエリが変わった場合にはuseMediaQueryの返す値が変わり、Reactコンポーネントが更新されるようになっています。

割とシンプルなコードで実現できました。 以下がuseMediaQueryの実装です。

import { useEffect, useState } from 'react'

const getCurrentMedia = (mediaList) => {
  let result = null;
  for (const media of mediaList) {
    if (window.matchMedia(media).matches) {
      result = media
      break
    }
  }
  return result
}

const useMediaQuery = (mediaList) => {
  const [current, setCurrent] = useState(getCurrentMedia(mediaList))

  useEffect(() => {
    let mounted = true
    let timeout
    const onResize = () => {
      // 500msに一度しかresizeイベントのcallbackを実行しないようにする
      if (timeout) return

      const media = getCurrentMedia(mediaList)
      if (current !== media) {
        setCurrent(media)
      }

      timeout = setTimeout(() => timeout = null, 500)
    }

    window.addEventListener('resize', onResize)

    return () => {
      mounted = false
      window.removeEventListener('resize', onResize)
    }
  }, [])

  return current
}

export default useMediaQuery

実装はreact-useuseOrientationを参考にしました。 ReactのVirtualDOMの外の世界であるwindow.innerWidthやresizeイベントをカスタムフック内に隠蔽し、 useStateを利用してその結果だけをReactの世界で管理するようになっています。 これにより、実装時にはresizeイベントのことを気にせず、文字列としてメディアクエリを取得することが出来るようになります。

利用する際は以下のように表示を切り分けたいメディアクエリの配列を渡すことで、現在の状態にマッチするものが返されるので、その値を利用して描画処理を行います。

import { useMemo } from 'react'
import 'useMediaQuery' from './useMediaQuery'

const QUERY_SP = "(max-width: 640px)"
const QUERY_PC = "(min-width: 641px)"

const Comp = (props) => {
  // queryには現在の画面幅に応じて、QUERY_SPもしくはQUERY_PCの文字列が返される
  const query = useMediaQuery([QUERY_SP, QUERY_PC])
  const text = useMemo(() => {
    switch(query) {
      case QUERY_SP:
        return "スマートフォン"
      case QUERY_PC:
        return "PC"
    }
  }, [query])

  return <span>現在は{text}で表示しています。</span>
}

export default Comp

こんな感じで、実装している側ではresizeイベントを全く考慮すること無く実装することが出来ました。

結構個人的には革命的だったというか、Reactでどうしても実装が綺麗に出来ないと思ってたところがとてもすっきりしたので、 仕事中にもかかわらず思わず興奮してしまいました。

そのうちuseMediaQueryはテストコード書いたり、言語をTypeScriptで書き直した上でGitHub上で公開しようかなとは思っています。 カスタムフックのライブラリってどういう粒度で公開したらいいのかちょっと悩ましいですね。 フック1つでライブラリ1つにすると、なんか大量のライブラリを管理しなきゃいけなさそうで面倒だなあとは思いつつ、 react-useみたいにユーティリティライブラリみたいにする場合、他にどういうカスタムフックとセットにすべきかというのもなかなか悩ましいところ。