色を決めるためにJSを待つな:light-dark() が問い直したテーマ制御の責務

はじめに
Webアプリがダークモードに対応するとき、
だいたいこうする。
localStorage を読む。useEffect で document に属性を付ける。
Reactがその属性を見て、色を切り替える。
私もそうしていた。
それが「当たり前」だと思っていた。ThemeToggleButton.tsx を書いたときは。
ブラウザはとっくに、ユーザーの好みを知っていたのに。
この記事をお勧めしない人
- useEffectで動いていればそれでいいと思っている人
- FOUCを「許容範囲」と判断している人
- 技術の選択より、実装の完成を優先している人
もし一つでも当てはまらないなら、読み進める価値があるかもしれません。
【思想衝突】
- Technical Control vs DX
- Browser Native vs Framework Magic
① 問題提起:useEffect テーマ制御が払っている暗黙のコスト
useEffect で localStorage を読み、document.documentElement に属性を付与するパターンは、動く。テストも書ける。チームにも説明しやすい。
問題は、それが 【Hydrationという待機を前提にしている】 ことだ。
- 【FOUC】:HTML到達 → JS実行 → Hydration完了、という順序を経るまでテーマが確定しない。一瞬、色が化ける。
- 【責務の誤配置】:ブラウザはOSから
prefers-color-schemeをすでに受け取っている。それを無視してJSが色を決め直している。 - 【SI(Speed Index)への影響】:テーマが確定するまでのレンダリングブロックが、知覚的なロード速度を押し下げる。
これは「間違い」ではない。その時点での合理的な選択の副産物だ。
② なぜ皆そう作るのか
ダークモード対応が広まった初期、CSSには prefers-color-scheme メディアクエリがあった。しかしユーザーが「明示的に切り替える」手段はなかった。OSの設定に連動するだけで、「このサイトだけライトで見たい」という選択肢が存在しなかった。
だからJSが担った。localStorage に保存し、初期ロード時に読む。合理的だった。
ReactとHydrationが普及すると、この構造がそのまま引き継がれた。SPA時代のパターンがSSR時代に持ち込まれ、「初期化コードとして useEffect でDOM操作する」ことが当たり前になった。
| 当初解決しようとした問題 | 生まれた副産物 |
|---|---|
| OSの設定に依存しない明示的切り替え | JS実行まで色が確定しない |
| Reactコンポーネント内で状態を一元管理 | ブラウザが知っていることをJSが重複処理 |
| localStorageによる永続化 | SSR環境でサーバーがユーザー設定を知れない |
誰かが間違えたのではない。その時点の制約に対する答えが、今の環境では負債になった。
③ light-dark() は何を優先したのか
light-dark() は技術的な改善ではない。【価値の再配置】だ。
light-dark() が選んだのは「【ブラウザがすでに知っていることをブラウザに任せる】」という原則だ。それは「Reactが状態を一元管理する」という原則の一部を意図的に後退させることで成立している。
優先した価値
- 【レンダリングの透明性】:CSSが解析された時点で色が確定する。JSの実行を待つ待機が存在しない。
- 【Web標準への準拠】:
prefers-color-schemeとcolor-schemeはW3C仕様。フレームワークが変わっても動く。 - 【FOUCの構造的排除】:テーマ制御の責務がHTMLとCSSに移るため、Hydration失敗時もテーマが崩れない。
意図的に後退させた価値
- 【Reactによる状態一元管理】:テーマの「表示」はCSSとサーバーが持ち、Reactはトグル操作だけを担う。React側から「現在のテーマ」を読む場合は別の経路が必要になる。
- 【実装の単純さ】:
useEffect一本で完結していた構造が、CSS・Cookie・loaderの3層に分散する。
④ 実装でどう解決しているか
`light-dark()` 関数:JSなしで色を決める
CSS Color Level 5 の light-dark() 関数は、color-scheme プロパティと組み合わせることで、ブラウザが prefers-color-scheme を直接解決する。
普通なら document.documentElement.setAttribute('data-theme', ...) をJSで呼ぶ。
この設計は違う。
:root {
color-scheme: light dark;
--color-bg: light-dark(#ffffff, #0a0a0a);
--color-text: light-dark(#0a0a0a, #f5f5f5);
}JSの実行を待たない。CSSが解析された時点で色が確定する。
守ろうとした価値は「【ブラウザの判断をブラウザに返す】」ことだ。
【解決済み(一括移行)】:
data-themeセレクタをすべて削除し、色変数を全量light-dark()に書き換えた。段階的移行は採用しなかった。data-themeとlight-dark()が混在する中間状態が最もデバッグしにくく、「どちらが今の色を決めているか」の把握コストが増大するため。
Cookieによるサーバーサイド・ファースト:ユーザーの明示設定をHTML時点に反映
prefers-color-scheme はOSの設定だ。ユーザーが「このサイトだけ明示的に切り替えた」好みは別に保存する必要がある。
localStorage を使えばクライアントで読める。しかしそれはHydrationを待つことを意味する。
Remixには別の経路がある。Cookieはサーバーで読める。loader でCookieを解析し、root.tsx の html タグに style として直接書き込む。
// root.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const cookieHeader = request.headers.get('Cookie');
const colorScheme = getColorSchemeFromCookie(cookieHeader); // 'light' | 'dark' | null
return { colorScheme };
}
export default function App() {
const { colorScheme } = useLoaderData<typeof loader>();
return (
<html lang="ja" style={colorScheme ? { colorScheme } : undefined}>
<head>...</head>
<body>...</body>
</html>
);
}【決定済み】: Cookie名
color-scheme、SameSite=Lax、Max-Age=31536000(1年)、Path=/。セッション管理との統合はしない。colorSchemeがnull(Cookie未設定)のときはhtmlにstyle属性を設定せず、CSS のcolor-scheme: light darkによりブラウザがOSの設定を直接解決する。ユーザーが明示的に切り替えて初めてCookieが生まれる設計にした。
HTMLが届いた瞬間、ブラウザはすでに colorScheme を知っている。
守ろうとした価値は「【サーバーが知れることはサーバーで解決する】」ことだ。
【解決済み】:
document.documentElement.style.colorSchemeをuseEffectで読む方針にした。ThemeToggleButton.tsxはトグル時にこの値を即時更新し、Cookie永続化は/api/themeへのuseFetcher.submitで非同期に行う。React state と CSS のcolor-schemeは常に同期する。useTheme()hook は作らなかった。コンポーネントがテーマを知る必要が生じた場合のみその時点で設計する。
⑤ 何を捨てたか
この設計を採用すると、以下を手放すことになる。
- 【CSS仕様の理解コスト】:
light-dark()は CSS Color Level 5 の仕様。color-schemeプロパティの挙動、prefers-color-schemeとの優先順位を理解する必要がある。 - 【ブラウザサポートの制約】:
light-dark()は2024年時点でChrome/Firefox/Safari最新版が対応済みだが、古いブラウザでは動作しない。
【決定済み(サポート外割り切り)】: サポート対象はChrome 101+、Firefox 120+、Safari 17.5+。これらは
light-dark()とcolor-schemeを完全サポートする。@supportsフォールバックは導入しなかった。旧ブラウザではlight-dark()が未解決のまま変数が無効化されるが、それを許容するサポートポリシーにした。「フォールバックを書く」より「サポート範囲を明示する」方が長期的に管理コストが低い。
- 【Reactによる状態の可視性】:
useTheme()のようなhookでコンポーネントから現在のテーマを読む場合、CookieとCSSのどちらを正とするかの設計判断が発生する。 - 【実装の局所性】:
useEffect一本だったものが CSS・Cookie・loader・action の4箇所に分散する。変更箇所を追うコストが上がる。
【これを許容できるかどうか】が、採用判断の核心になる。
⑥ ClaudeMix への適用
ClaudeMixでも、ThemeToggleButton.tsx は useEffect で localStorage を読み、document.documentElement に属性を付与していた。
「色を決める責務」をReactが持っていた。
ブラウザはOSから答えを知っていたのに。
変更は3層に分けて行った。
L1: globals.css — 色の決定権をCSSへ
/* Before:JSがdata-theme属性を付与して切り替える */
:root { --color-background-primary: #ffffff; }
html[data-theme="dark"] { --color-background-primary: #0c101b; }
/* After:ブラウザがcolor-schemeを読んで決定する */
:root {
color-scheme: light dark;
--color-background-primary: light-dark(#ffffff, #0c101b);
--color-text-primary: light-dark(#111827, #e6edf3);
--color-interactive-primary: light-dark(#006c73, #00f0ff);
}html[data-theme="light"] ブロックと html[data-theme="dark"] ブロックはすべて削除した。サービス固有の上書き(html[data-service="blog"])も同様に light-dark() で1ブロックに統合した。
【実装済み・問題なし】: Safari 17.5+ は
light-dark()を完全サポート。バグや挙動差異は発生しなかった。
L2: root.tsx — ユーザーの明示設定をHTML時点に反映
// loader:CookieからcolorSchemeを取得
export async function loader({ request }: LoaderFunctionArgs) {
const cookieHeader = request.headers.get('Cookie');
const colorScheme = getColorSchemeFromCookie(cookieHeader); // 'light' | 'dark' | null
return { colorScheme };
}
// HTML構築時点でstyle.colorSchemeを確定
<html
lang="ja"
style={colorScheme ? { colorScheme } : undefined}
>Cookie未設定(colorScheme === null)のときは style 属性なし。CSS の color-scheme: light dark がOSの設定を直接解決する。
【実装済み・問題なし】: loaderのCookie解析とHydration順序での競合は発生しなかった。サーバーが
style.colorSchemeを注入した状態でHTMLが届くため、クライアント側でのちらつきは構造的に起きない。
L3: ThemeToggleButton.tsx — 責務をトグル操作だけに縮小
// useFetcherでPOST、document操作で即時反映
const fetcher = useFetcher();
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
document.documentElement.style.colorScheme = newTheme;
fetcher.submit({ scheme: newTheme }, { method: 'POST', action: '/api/theme' });
};
// useEffectはCookieから注入された値を読むだけ
useEffect(() => {
const explicit = document.documentElement.style.colorScheme;
if (explicit === 'light' || explicit === 'dark') {
setTheme(explicit);
} else {
setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
}, []);localStorage の読み書きはゼロになった。data-theme 属性の付与もない。コンポーネントがやることは「state更新」「style.colorScheme書き込み」「Cookie POST」の3つだけ。
【FOUCは構造的に排除済み】: テーマ確定がCSS解析時点(=HTMLパース時)に前倒しされた。
useEffectの実行を待つ待機がなくなったため、初期レンダリングでのちらつきは発生しない。SI/LCPの定量計測(PageSpeed Insights)は未実施。
⑦ この設計は誰向けか
向いている
- FOUCを根絶したい:SSR環境でテーマのちらつきが許容できないプロジェクト
- SIスコアを改善したい:Speed Indexをパフォーマンス指標として追っているチーム
- Web標準を資産にしたい:フレームワーク乗り換えに対して耐性を持ちたいSolo開発者
- ブラウザの責務を整理したい:「どの層が何を知っているか」を設計に反映したいエンジニア
向いていない
- Reactエコシステムで完結させたい:コンポーネントからテーマを
useStateで扱いたい場合、この設計と相性が悪い - CSS仕様を調べたくない:
light-dark()のフォールバックやブラウザサポートを管理したくない場合、useEffectパターンの方がシンプルに見える - 大規模チームで変更範囲を最小化したい:4箇所に分散した実装はレビューコストが上がる
⑧ 思想の衝突
最近のフロントエンドは、「どれだけReactで完結させるか」に進んでいる。
状態管理をReactへ。テーマをReactへ。フォームもReactへ。
light-dark() とCookieは逆だった。
ブラウザの仕事をブラウザへ返す。
サーバーが知れることはサーバーで解決する。
Reactはインタラクションだけを担う。
その選択は「正しい」わけではない。
【「速く、Reactだけで完結させたい」】 という価値観のもとでは、useEffectパターンの方が一貫している。
【「透明性と標準準拠を優先する」】 という価値観のもとでは、この設計が一貫している。
どちらを選ぶかは、プロジェクトが何を優先するかだ。
【実装後の判断:変わらなかった】。Cookie設計は「名前・有効期限・SameSite」の3項目を決めるだけで完結した。
@supportsフォールバックも不要だった。「複雑になりすぎた」という懸念は杞憂だった。ただし、SSR環境でRemixのloaderが使えない場合(CSRのみのSPA等)には、この設計の前提が崩れる。その文脈ではuseEffect+Cookie手動読み出しのハイブリッドが現実的になる。
設計とは、何を便利にするかではない。
どの価値観を、どの順番で優先するかだ。
⑨ あなたはどちらを選ぶか
テーマの決定権は、Reactが持つべきか。ブラウザとサーバーへ返すべきか。
DXの高さと、FOUCの排除。どちらを先に置くか。
実装の局所性と、責務の明確さ。どちらが資産になるか。
ブラックボックスの最適化を許容するか。構造を理解することを優先するか。
この記事はClaudeMixで動いている。設計判断を二択で問い続けるプロダクトだ。あなたが今読んだ問いも、そこから来ている。
