Reactのカスタムフックで、OSのダークモード設定を読み取る方法
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 で返すフック
フックの全体です。
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 を呼んでも、設定が変わらないあいだは同じ値になります。
コンポーネントで使う
使う側では、フックを呼んで、返った値で表示を変えます。次は検証に使ったコンポーネントです(検証用の記録の行は省いています)。
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 をダーク、ライト、ダーク、ライトと切り替えたときの画面です。

次の記録は、GIF の画面とは別に用意した検証用の画面で取ったものです。この画面には、usePrefersDark を使うコンポーネント(sync)と、次の節で比べる useEffect の書き方のコンポーネント(effect)を並べています。OS の設定がダークの状態で画面を開いたあと、読み込み直さずに、OS の設定をライト、ダーク、ライトと切り替えました。add と remove は change イベントを登録・解除した回数、active は今登録されている数、events は2つのコンポーネントに届いた change イベントの合計です。
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 を読み直す書き方もあります。
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 に入った表示の順番と、描画したときの値を記録しました。
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 です。
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回だけでした。
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 になりました。
usePrefersDark: <div class="card" id="card"><p>OS の設定:<b id="value">ライト</b></p></div>
公式リファレンスのサーバーでの描画の説明によると、getServerSnapshot が使われるのは、サーバーで HTML を作るときと、ブラウザでその HTML をハイドレーションするときです。このライトの HTML を、OS の設定をダークにしたブラウザで hydrateRoot に渡しました。
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 で値が必要なところに使います。





