
先日Panda CSSでデザインシステムのコンポーネントライブラリを作成した際の記事を投稿させていただきました。
そこでちょっと触れた、ランタイムで動作するCSS-in-JSはRSCとの噛み合わせがあまり良くないので、そもそもの選定候補から外したという話を書かせていただきました。
その話をちょっと深ぼろうと思います。
「styled-componentsはRSCと相性が悪い」という話はなんとなく聞いたことはあるけど、本当のところ何が問題なのか?
- CSS-in-JSとは
- SSR(サーバサイドレンダリング)とDOMの関係を整理
- 本当にDOM/CSSOMが必要になるのはどんなとき?
- styled-componentsはどうやってSSRに対応していたのか
- App Routerで何が壊れたのか
- Panda CSSはなぜこれを全部回避できるのか
- 参考リンク
- おわりに
CSS-in-JSとは
CSS-in-JSとは、CSSをJavaScriptのコードとして記述するアプローチの総称です。styled-componentsやEmotionが代表的な実装で、こんなふうに書きます。
const Button = styled.button` background: ${props => props.primary ? '#007bff' : 'white'}; color: ${props => props.primary ? 'white' : '#007bff'}; padding: 8px 16px; border-radius: 4px; `;
CSSをJavaScriptで書く、という発想は一見ただの奇行に見えます。
2014年当時、この考えを最初に提唱したのはFacebookのフロントエンドエンジニアだったChristopher Chedeau(通称:vjeux)です。彼が公開したスライド「React: CSS in JS」のタイトルはそのものずばりです。今でも公開されており、1.5M回以上閲覧されています。
冒頭でvjeuxはこう語ります。
「まずCSSが大規模開発で抱えている問題を全部話す。そうしないと、いきなりJSの話をしても『こいつ頭おかしい』と思われて終わるから」
彼が列挙したCSSが大規模開発で抱えている問題は7つです。簡単に書きます。
1. Global Namespace(グローバル名前空間)
CSSのクラス名はすべてグローバル変数です。.buttonと書けば、そのページのどこからでも上書きできてしまいます。JavaScriptではとっくに「グローバル変数は悪」という合意ができていたのに、CSSだけはずっとグローバルのままでした。Bootstrapは当時600個超のグローバルクラスを持っていた、とスライドで皮肉られています。
2. Dependencies(依存関係)
JSにはimport/requireがありますが、CSSの依存管理は手動のrequireCSS()呼び出しに頼っており、別のファイルがすでに読み込んでいた場合は「たまたま動く」状態になります。意図せず動いているコードは、意図せず壊れます。
3. Dead Code Elimination(デッドコード除去)
CSSの死んだコードを見つけることは、当時ほぼ不可能でした。.old-buttonというクラスが今でも使われているかどうか、grepでは追いきれない。消して壊れてから初めてわかる、というのが現実でした。
4. Minification(ミニファイ)
JSのビルドツールは変数名を短縮できますが、CSSのクラス名はHTMLとの文字列マッチで成立しているため、圧縮しにくい構造になっていました。
5. Sharing Constants(定数の共有)
BREAKPOINT_MD = 768pxのような値をCSSとJSで共有したいとき、コメントで「ここ変えたらあっちも変えてね」と書くしかなく、当然のように同期漏れが起きていました。
6. Non-deterministic Resolution(非決定論的な解決)
同じ特異度(specificity)を持つ2つのCSSルールがある場合、どちらが勝つかはファイルの読み込み順に依存します。非同期でCSSを動的ロードする場合、ページの遷移経路によって見た目が変わるバグが発生します。再現手順が「AページからBページに遷移すると崩れる」という類の、デバッグ困難なバグです。
7. Isolation(分離)
Facebookにはボタンやドロップダウンなど共通コンポーネントを管理するチームがいましたが、利用側はdiv > .button > spanのようなセレクタでコンポーネント内部のスタイルを勝手に上書きできてしまう。コンポーネントの内部実装を変えると、どこかで必ず壊れる。これがコンポーネント開発者を「コードを変えることへの恐怖」に陥れていました。
これらの問題に対してvjeuxが提示した答えが「スタイルをJavaScriptオブジェクトで書き、インラインスタイルとして渡す」というアプローチでした。
const styles = { container: { backgroundColor: 'white', padding: 8, borderRadius: 4, }, depressed: { backgroundColor: '#999', } }; // isDepressedに応じてスタイルをマージ <div style={m(styles.container, isDepressed && styles.depressed)}>
JSオブジェクトにするだけで、先ほどの7つの問題はほぼすべて消えます。
- クラス名はJSのローカル変数になり、グローバル汚染がない
importでCSSの依存関係を管理できる- 未使用変数はlinterやminifierが検出できる
- 定数はJSの変数として当然に共有できる
- 後勝ちルールではなくオブジェクトのマージ順で確定するため非決定性がない
- セレクタで内部に潜り込めないのでコンポーネントが本当に隔離される
vjeuxがスライドの最後で述べていた言葉が印象的です。
「JSをCSSの代わりに使えと言いたいわけじゃない。CSSには誰も語らない根本的な問題があることを伝えたかっただけだ」
このスライドをきっかけに、CSS-in-JSというジャンル自体が爆発的に広がりました。styled-components(2016年)、Emotion(2017年)はいずれもこの流れから生まれています。
ただし彼が提示した「インラインスタイルアプローチ」はそのままでは疑似クラス(:hover)やメディアクエリが使えないという限界がありました。そこで後発ライブラリは「実行時にCSSクラスを動的に生成してDOMに注入する」という方式に進化します。これが、後のRSC(React Server Component)との相性問題の火種になります。
SSR(サーバサイドレンダリング)とDOMの関係を整理
ここから本題の、ランタイムCSS-in-JSがRSC時代(w/ Next.js App Router)に厳しい理由を紐解いていきます。 混乱しやすいポイントから先に潰しておきます。
SSRはDOMを使っていない
Next.jsのrenderToString/renderToReadableStreamは、Node.js上で動く純粋なJavaScript関数で、内部では仮想DOMを文字列にシリアライズしているだけです。本物のDOMは介在しません。
// React内部の擬似コード function renderToString(element) { if (typeof element.type === 'string') { let html = `<${element.type}` for (const [key, value] of Object.entries(element.props)) { if (key === 'style') { // styleオブジェクトを"key: value;"形式の文字列に変換 html += ` style="${serializeStyle(value)}"` } else if (key === 'className') { html += ` class="${value}"` } } html += '>' // 子要素を再帰的に文字列化 for (const child of element.props.children) { html += renderToString(child) } return html + `<${element.type}>` } // 関数コンポーネントは実行して結果を再帰 if (typeof element.type === 'function') { return renderToString(element.type(element.props)) } }
ポイントは、一行もdocument.xxxが出てこない ということ。ただの文字列連結です。
「仮想DOM」と「DOM」は別物だと押さえておきましょう。
| 概念 | 何か | どこで動くか |
|---|---|---|
| 仮想DOM | JavaScriptのオブジェクトツリー(React Element) | サーバー・ブラウザ両方 |
| DOM | ブラウザが提供するdocument配下のAPI群 |
ブラウザのみ |
<div style={{height: '900px'}}>というJSXは、コンパイル後にこういうオブジェクトになります。
{ type: 'div', props: { style: { height: '900px' }, children: 'hoge' } }
これはただのプレーンなJavaScriptオブジェクトで、Node.jsでも作れます。Reactのサーバーレンダリングは、このオブジェクトツリーを再帰的に走査して文字列にシリアライズしているだけ、という話です。
この理解に立つと、以下はすべてRSCで普通に動きます。
// インラインstyle <div style={{ height: '900px', color: 'red' }}> // <style>タグを直接埋め込む <style>{`.foo { color: red; }`}</style> // classNameでCSSファイルのクラスを参照 <div className="foo"> // Tailwind/PandaのクラスCSS <div className={css({ color: 'red' })}>
全部「文字列としてHTMLを出力する」というだけの処理なので、サーバーで実行できます。「DOMが無いから動かない」という直感は、ここでは外れます。
本当にDOM/CSSOMが必要になるのはどんなとき?
DOM APIやCSSOM APIが必要になるのは、ブラウザ上のオブジェクトを直接操作する場面です。
// ブラウザでしか動かないAPI // DOM API document.createElement('style') element.style.height = '900px' document.head.appendChild(...) // CSSOM API document.styleSheets[0].insertRule('.foo { color: red; }', 0)
styled-componentsやEmotionが内部で使っているのは、これらのAPIです。コンポーネントがレンダリングされるたびに、
- propsから動的にCSS文字列を生成
document.styleSheetsにルールを追加(ブラウザ)← CSSOM API- ハッシュ化されたクラス名をDOMに付与
という処理を行います。ステップ2がブラウザ専用APIなので、サーバーでは別の経路が必要、というわけです。
styled-componentsはどうやってSSRに対応していたのか
styled-componentsはこの問題をServerStyleSheetという抽象化で解決していました。
// styled-componentsのSSR処理(擬似コード) import { ServerStyleSheet } from 'styled-components' const sheet = new ServerStyleSheet() // CSS収集用の仮想シート const html = renderToString(sheet.collectStyles(<App />)) const styleTags = sheet.getStyleTags() // 収集したCSSを<style>タグとして取得 return ` <html> <head>${styleTags}</head> <body><div id="root">${html}</div></body> </html> `
つまり、
- レンダリング中、各
styled.divが「自分のCSSをsheetオブジェクトに登録」する - レンダリング完了後、
sheetに溜まったCSSを<style>タグの文字列として吐き出す - それをHTML文字列の
<head>に埋め込む
ブラウザではdocument.styleSheets、サーバーではServerStyleSheetという独自の抽象化を使い分けることでSSRに対応していたわけです。
App Routerで何が壊れたのか
Pages RouterのSSR
renderToStringで 一括レンダリング → 全スタイル収集 → HTML一括返却。シンプルに2ステップでした。
App RouterのStreaming SSR
App RouterはStreaming SSRを採用しており、HTMLを少しずつブラウザに送り始める動作をします。
「レンダリングが全部終わってから<head>にスタイルを注入」する従来のやり方は、「<head>はもうブラウザに送ってしまった後だ」という状況 で破綻します。後から<head>にCSSを足したくても、もう送り終わっているから無理。
これに対応するためNext.jsはuseServerInsertedHTMLという「ストリーム途中でHTMLを差し込むためのフック」を用意しました。styled-componentsをApp Routerで使うには、これを使ったStyledComponentsRegistryを自分で書く必要がありました。
ここで注目すべきは、
- このRegistryは
'use client'コンポーネントとして書く - 配下のすべてが事実上クライアント境界に入る
- ストリーミングの中で何度もスタイルを注入する仕組みになっている
つまり「動かそうと思えば動くが、RSCの利点を多くの場面で諦めることになる」というのが現実です。
「RSCで動くか動かないか」ではなく、「RSCの恩恵を受けられるか」が大事ですね。
styled-componentsは動きます。Next.jsの公式統合手段もあります。ただし以下の代償を払うことになります。
- ThemeProviderがContext依存 —
useTheme()はContext APIなのでサーバーコンポーネントで使えない。テーマを使うコンポーネント配下はすべて'use client'化する必要がある styled.divの戻り値がクライアントコンポーネント — 内部でフックやContextを使っているので、サーバーコンポーネント内で直接使うとエラーになる- Streaming SSRに独自統合が必要 —
StyledComponentsRegistryのような追加実装が必要 - ランタイムライブラリがクライアントJSに乗る — RSCの「JSを最小化する」思想と逆行する
※2026年現在、styled-components v6.3.0+ではRSCがネイティブサポートされ、Registryなしでも動作するようになりました。ただし
ThemeProviderがno-op化される、:nth-child()セレクタの挙動が変わる、ランタイムライブラリがクライアントJSに乗るというトレードオフは残っており、「動くが、RSCの恩恵を全部は受けられない」という本質は変わってなさそうです。
Panda CSSはなぜこれを全部回避できるのか
Panda CSSやTailwind CSSといった所謂ゼロランタイムCSS-in-JSは、ビルド時にASTを解析してアトミックCSSクラスを静的に切り出すので、ランタイムにスタイル生成のコードが残りません。
<div className={css({ color: 'red.500', p: '4' })}>
このコードに対して、Panda CSSのビルドツールが、
- ソースコードのASTを解析
css(...)の呼び出しを検出- オブジェクトリテラルから対応するアトミックCSSクラスを生成
css(...)の呼び出しを「クラス名の文字列」に置き換える
という処理を行います。ランタイムではcss()関数は事前計算されたクラス名を返すだけで、CSSの生成も注入も行いません。
結果として、
- Reactのフックにも、Contextにも、CSSOM APIにも依存しない
- サーバーコンポーネント内で自然に動作する
<link rel="stylesheet">で静的CSSを読み込むだけなので、Streaming SSRの問題も発生しない- ランタイムライブラリがクライアントJSに乗らない
「CSS-in-JSの書き味」と「ゼロランタイム」を両立できるPanda CSSやTailwind CSSがRSC時代のスタイリングライブラリの選択肢として強い理由ですね。
参考リンク
- Panda CSS — 公式サイト
- Tailwind CSS
- styled-components
- Emotion
- Next.js: CSS-in-JS (App Router)
- React: Server Components
おわりに
KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp