Reactのカスタムフックで、OSのダークモード設定を読み取る方法

​​React​Playwright,TypeScript,useSyncExternalStore,カスタムフック

OS がダークモードかどうかは、window.matchMedia('(prefers-color-scheme: dark)') で読めます。これを useSyncExternalStore で包んだカスタムフックにすると、React のコンポーネントは最初の描画から正しい値を受け取り、OS の設定が切り替わったときにも描画し直します。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.ts / .tsx を tsc で型チェックしてから esbuild で開発用に変換し、Chrome for Testing 153(Chromium)を Playwright 1.64.1 で操作しました。OS の設定は、実際の OS の画面では変えていません。Playwright の機能で、ブラウザに prefers-color-scheme の値を指定して切り替えました。Firefox や Safari、スマホの実機、本番用ビルドでは試していません。サイトの中でユーザーが選ぶテーマ(選んだ値を保存するもの)も、この記事では扱いません。

OS の設定を true / false で返すフック

フックの全体です。

ts
import { useSyncExternalStore } from 'react';

const QUERY = '(prefers-color-scheme: dark)';

// OS の設定が変わったら、React に知らせる
function subscribe(onChange: () => void) {
  const mql = window.matchMedia(QUERY);
  mql.addEventListener('change', onChange);
  return () => mql.removeEventListener('change', onChange);
}

// 今の設定を読む
function getSnapshot() {
  return window.matchMedia(QUERY).matches;
}

// サーバーでは OS の設定が分からないので、ライトとして描く
function getServerSnapshot() {
  return false;
}

export function usePrefersDark() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

MDN の matchMedia のページにあるとおり、matchMedia が返す MediaQueryList の matches で、今その条件に合うかどうかが分かります。条件に合うかどうかが変わると、MediaQueryList の change イベントが届きます。

MDN の prefers-color-scheme のページによると、この値は OS の設定か、ブラウザの設定から決まります。light は「ライトを選んだ」だけでなく「特に選んでいない」場合も含みます。そのため、このフックの false は「ダークではない」という意味です。

React の外で変わる値を読むためのフックが useSyncExternalStore です。公式リファレンスにも、ブラウザが持っている値を読む例があります。渡す関数は3つです。subscribe は、値が変わったときに呼ぶ関数を受け取って登録し、登録を解除する関数を返します。getSnapshot は今の値を返します。getServerSnapshot は、サーバーで描くときの値を返します。

getSnapshot は、値が変わっていないあいだは同じ値を返す必要があります。React は返った値を Object.is で比べます。ここで返すのは true か false なので、毎回 matchMedia を呼んでも、設定が変わらないあいだは同じ値になります。

コンポーネントで使う

使う側では、フックを呼んで、返った値で表示を変えます。次は検証に使ったコンポーネントです(検証用の記録の行は省いています)。

tsx
import { usePrefersDark } from './usePrefersDark';

export function ThemeCard() {
  const dark = usePrefersDark();
  return (
    <div className={dark ? 'card dark' : 'card'} id="card">
      <p>OS の設定:<b id="value">{dark ? 'ダーク' : 'ライト'}</b></p>
    </div>
  );
}

Playwright の page.emulateMedia() で、ブラウザの prefers-color-scheme をダーク、ライト、ダーク、ライトと切り替えたときの画面です。

OS の設定がダークの状態で開いた画面から、ライト、ダーク、ライトと3回切り替えると、カードの表示が「ライト」「ダーク」「ライト」と変わり、カードの背景も白と濃い色に入れ替わる。切り替わった直後は、カードの枠と、ライト・ダークの文字がピンクになる
OS の設定を3回切り替えると、フックの値に合わせてカードの表示と背景が変わる動き

次の記録は、GIF の画面とは別に用意した検証用の画面で取ったものです。この画面には、usePrefersDark を使うコンポーネント(sync)と、次の節で比べる useEffect の書き方のコンポーネント(effect)を並べています。OS の設定がダークの状態で画面を開いたあと、読み込み直さずに、OS の設定をライト、ダーク、ライトと切り替えました。add と remove は change イベントを登録・解除した回数、active は今登録されている数、events は2つのコンポーネントに届いた change イベントの合計です。

text
light に切り替え: sync=ライト effect=ライト 描画の回数 sync=2 effect=3 change={"add":2,"remove":0,"active":2,"events":2}
dark に切り替え: sync=ダーク effect=ダーク 描画の回数 sync=3 effect=4 change={"add":2,"remove":0,"active":2,"events":4}
light に切り替え: sync=ライト effect=ライト 描画の回数 sync=4 effect=5 change={"add":2,"remove":0,"active":2,"events":6}

1回切り替えるごとに change イベントが届き、どちらのコンポーネントも1回ずつ描画し直して、表示が設定に追いつきました。

useEffect で読み直す書き方との違い

最初は false で描いておき、useEffect の中で matchMedia を読み直す書き方もあります。

ts
import { useEffect, useState } from 'react';

// 比べるための書き方:最初は false で描き、エフェクトの中で読み直す
export function usePrefersDarkEffect() {
  const [dark, setDark] = useState(false);

  useEffect(() => {
    const mql = window.matchMedia('(prefers-color-scheme: dark)');
    setDark(mql.matches);
    const onChange = (e: MediaQueryListEvent) => setDark(e.matches);
    mql.addEventListener('change', onChange);
    return () => mql.removeEventListener('change', onChange);
  }, []);

  return dark;
}

同じ検証用の画面を OS の設定がダークの状態で開き、2つの書き方を並べて、DOM に入った表示の順番と、描画したときの値を記録しました。

text
DOM に入った順: ["sync=ダーク effect=ライト","sync=ダーク effect=ダーク"]
描画した値: {"sync":["dark"],"effect":["light","dark"]}

usePrefersDark は、最初の描画からダークでした。useEffect の書き方は、一度ライトで描いて DOM に入れ、そのあとダークで描き直しています。公式リファレンスの useEffect の注意書きには、クリックなどの操作から起きたのではないエフェクトは、ふつうはブラウザが画面を描いたあとに動く、と書かれています。ダークの設定の人には、ライトの表示が一瞬見えるおそれがあります。この検証では DOM の変化を記録しただけで、画面に描かれた瞬間までは確かめていません。

subscribe は、コンポーネントの外か useCallback で作る

subscribe をフックの中で (onChange) => { ... } のように直接書くと、描画のたびに新しい関数になります。公式リファレンスの useSyncExternalStore の注意書きにあるとおり、別の subscribe が渡されると、React は登録し直します。この点は、以前の記事「useSyncExternalStoreで、Reactの外にあるストアを安全に読む方法」でも確かめました。ここでは同じことを、matchMedia の change イベントの登録で確かめました。親のコンポーネントにある、数を1増やすだけのボタン(+1)を3回押して描画し直し、change イベントの登録(add)と解除(remove)の回数を数えました。generic は、この節の後半で紹介する useMediaQuery です。

text
sync: マウント直後 {"add":1,"remove":0,"active":1,"events":0} / +1 を3回押したあと {"add":1,"remove":0,"active":1,"events":0}
inline: マウント直後 {"add":1,"remove":0,"active":1,"events":0} / +1 を3回押したあと {"add":4,"remove":3,"active":1,"events":0}
generic: マウント直後 {"add":1,"remove":0,"active":1,"events":0} / +1 を3回押したあと {"add":1,"remove":0,"active":1,"events":0}

subscribe をフックの中で直接書いた場合(inline)は、描画し直すたびに解除と登録をやり直していました。subscribe をモジュールの一番上に置いた usePrefersDark(sync)は、最初の1回だけでした。

(prefers-color-scheme: dark) 以外のメディアクエリにも使いたいときは、クエリを引数にして、subscribe を useCallback で包みます。次の useMediaQuery(上の結果の generic)も、登録は1回だけでした。

ts
import { useCallback, useSyncExternalStore } from 'react';

// どのメディアクエリにも使える形。query が変わったときだけ登録し直す
export function useMediaQuery(query: string, serverValue = false) {
  const subscribe = useCallback(
    (onChange: () => void) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', onChange);
      return () => mql.removeEventListener('change', onChange);
    },
    [query],
  );

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => serverValue,
  );
}

サーバーで描くときは getServerSnapshot を渡す

サーバーで描くとき、コンポーネントはブラウザの外で動くので、window がなく、OS の設定も分かりません。getServerSnapshot を渡さないと、サーバーでの描画がエラーになります(以前の記事「useSyncExternalStoreで、Reactの外にあるストアを安全に読む方法」で確かめています)。usePrefersDark を renderToString で描くと、getServerSnapshot の false を使い、ライトの HTML になりました。

text
usePrefersDark: <div class="card" id="card"><p>OS の設定:<b id="value">ライト</b></p></div>

公式リファレンスのサーバーでの描画の説明によると、getServerSnapshot が使われるのは、サーバーで HTML を作るときと、ブラウザでその HTML をハイドレーションするときです。このライトの HTML を、OS の設定をダークにしたブラウザで hydrateRoot に渡しました。

text
OS の設定 dark: DOM の変化 ["value=ライト","value=ダーク"] 描画した値 {"card":["light","dark"]} class="card dark"
  コンソール: []
OS の設定 light: DOM の変化 ["value=ライト"] 描画した値 {"card":["light"]} class="card"
  コンソール: []

ダークの設定では、ハイドレーションのときはサーバーと同じライトで描き、そのあとダークで描き直しました。コンソールに、サーバーとの食い違いのエラーは出ていません。ライトの設定では、描き直しはありませんでした。

ただし、サーバーの HTML はライトの表示で届くので、ダークの設定の人にも、ハイドレーションが終わるまではライトの表示が出ます(画面に描かれた瞬間までは確かめていません)。JavaScript を待たずに色を合わせたいなら、色そのものは CSS の @media (prefers-color-scheme: dark) で切り替えます。MDN の prefers-color-scheme のページにも、ライトの色を基本にしてダークで上書きする例があります。

色は CSS で、値が要る処理はフックで

ポイント

OS のダークモードの設定は、matchMedia('(prefers-color-scheme: dark)') を useSyncExternalStore で包んだフックで読みます。subscribe はコンポーネントの外に置くか useCallback で包み、サーバーで描くなら getServerSnapshot を渡します。色を変えるだけなら CSS のメディアクエリで済むので、フックは、グラフに渡す色や、表示を出し分ける部品など、JavaScript で値が必要なところに使います。

​Posted by 86