📵

【React】useMediaQuery は最終手段にしよう

2024/07/03に公開

こんにちは、エンジニアです。

本記事ではuseMediaQueryを使うべきではない理由を説明します。

useMediaQueryとは

window.matchMediaの判定結果を取得するカスタムフックを指します。

const isWideScreen = useMediaQuery("(min-width: 768px)");

window.matchMediaはCSSでできるメディアクエリの判定をJavaScriptでも可能にするブラウザAPIです。それをReact Hooksと組み合わせることで、宣言的に判定を行えるようなカスタムフックとなります。

過去にuseSyncExternalStoreを使って実装する記事を書いたので参考にしてみてください。

https://zenn.dev/stin/articles/use-sync-external-store-with-match-media

上の記事ではuseSyncExternalStoreの使い道を説明するのが目的だったのですが、Reactのフックとしてメディアクエリを使える、使っても良いと捉えられてしまったようで、題材選びを少し反省しています(?)

以下はユーティリティ系ライブラリによる提供例です。関数名は提供元によってバラツキがありますが、おおよそ同じことをしています。

問題点

useMediaQuerywindowオブジェクトに依存しています。つまりブラウザ上でしか正しく判定できません。

一方で昨今はサーバーサイドレンダリング(SSR)でHTMLを生成してからブラウザ上でイベントハンドラーを復元するhydrationを行います。サーバーサイドにはwindowオブジェクトがないため、固定値を返すかエラーを投げるしかありません。

サーバーサイドで固定値を返す場合、その固定値と実際のブラウザ上で計算される値が異なる可能性があります。作りが悪いカスタムフックだと、これがhydrationエラーとしてレポートされることになります。作りが良くて(?)useSyncExternalStoreuseEffectを上手く駆使しているとしても、それをスタイリングの制御に使っている場合はレイアウトシフトとして悪影響を及ぼします(本記事では適用されるCSSが突然変化してチラチラして見える現象を総称してレイアウトシフトと呼ぶことにします)。

レイアウトシフトになるのは、以下の順序で処理が行われるためです。

  1. サーバーサイドで固定値を使ってHTMLが生成される
  2. ブラウザが固定値を使って生成されたHTMLを画面に反映する
  3. 画面反映後useEffectmatchMediaの計算を実行する
  4. matchMediaの計算結果を改めて画面に反映する

敢えてuseEffectと書いたのはuseSyncExternalStoreを使ってもuseEffectと変わらないと言えるためです。それは React17 以前のバージョン向けに用意されたuseSyncExternalStoreのポリフィルから判断できます。ポリフィルのソースコードを見ると、useSyncExternalStoreuseEffect(またはuseLayoutEffect)で代替実装ができるフックです。

https://github.com/facebook/react/blob/3db98c917701d59f62cf1fbe45cbf01b0b61c704/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js

解決策

スタイリングの制御に使うなら、まずCSSのメディアクエリで実装できないか検討してください。CSSで実装すれば最初からブラウザのサイズが考慮されたスタイリングが当たり、レイアウトシフトの原因にはなりません。

スタイリングではない箇所、例えばイベントハンドラーやuseEffectで画面幅を取りたい場合は、それらの関数の中で計算すればよいです。カスタムフックは必要ありません。

使っても良い箇所

SSR対応フレームワークを使用していない(ブラウザで空のHTMLにSPAをマウントして使っている)のであれば、どんな箇所でも使用可能です。

SSR対応フレームワークでも、クライアントサイドレンダリングだけで使用されるコンポーネントなら使っても悪い現象は起きません。ユーザーインタラクションによって初めて表示されるダイアログなどが該当します。ちなみに、「クライアントサイドレンダリングだけ」というのは"use client"を付けることではありません。

dynamic importされるコンポーネントでも良いです。dynamic importされたコンポーネントはクライアントサイドレンダリングであることが保証されます。Next.js ならnext/dynamicも含みます。

@uidotdev/usehooksにはuseIsClientというカスタムフックがあり、その戻り値でクライアントサイドかを判定してコンポーネントを表示するかどうか決めることもできます。

色々回避策はありますが、スタイリングを切り替えたいだけならやはりCSSだけで実装できないか検討してください。スタイリングはCSSの役割です。

@uidotdev/usehooksの方針

@uidotdev/usehooksはブラウザJavaScriptのAPIを使用するカスタムフックがサーバーサイドレンダリング中に実行される場合、エラーをthrowする方針のようです。

https://github.com/uidotdev/usehooks/issues/254#issuecomment-1778088998

「hydrationエラーをごまかすためだけの作り方は好きではない」という強い意思を持ってエラーとしているようで、僕はとても好きな方針です。

まとめ

  • useMediaQueryは使わずに、CSSのメディアクエリで実現できないかを検討する
  • 仮に使うことになっても、必ずユーザーに違和感を与えるチラツキを抑える
  • ライブラリがSSRでエラーをthrowするのは思想なので従おう
  • 回避策はあるけど回避するな

それでは良いReactライフを!

GitHubで編集を提案
chot Inc. tech blog

Discussion