1. ホーム
  2. React

【React】useInsertionEffect の使い方|CSS-in-JS でスタイルを挿入するためのフックを解説

Share

useInsertionEffect は React 18 で追加されたフックですが、これはアプリを作る人のためのものではなく、CSS-in-JS ライブラリを作る人のためのフックです。React の公式ドキュメントにも「useInsertionEffect は CSS-in-JS ライブラリの作者向けです」と明記されています。普段のアプリ開発では useEffectuseLayoutEffect で足ります。この記事では、それでもこのフックが用意されている理由と、3つのエフェクトフックの実行順序の違い、実際の使い方と制約を解説します。

スタイルの挿入が遅れるとレイアウト計測がずれる

CSS-in-JS には大きく3つのやり方があります。コンパイラで CSS ファイルへ静的に切り出す方法、style={{ opacity: 1 }} のようにインラインスタイルを使う方法、そして実行時に <style> タグを DOM へ挿入する方法です。React 公式は前の2つの組み合わせを推奨していて、3つ目の実行時挿入は推奨していません。ブラウザがスタイルを再計算する回数が増えることと、React のライフサイクルの中で挿入するタイミングを誤ると非常に遅くなることが理由です。

このうち後者の「タイミング」の問題を解決するために用意されたのが useInsertionEffect です。仮に useLayoutEffect の中で <style> タグを挿入したとすると、同じ useLayoutEffect の中で getBoundingClientRect() などを使ってレイアウトを計測している別のコンポーネントは、スタイルが当たる前の状態を読んでしまう可能性があります。実行順序次第で計測値がずれたり、後から挿入されたスタイルによってレイアウトの再計算が発生したりするわけです。

useInsertionEffectあらゆるレイアウトエフェクト(useLayoutEffect)より前に実行されることが保証されています。レイアウトを読む処理が動き出す前にスタイルを入れ終えられる、というのがこのフックの唯一かつ最大の存在理由です。

3つのエフェクトフックの実行タイミングの違い

React のエフェクト系フックは3つあり、実行される順番が決まっています。整理すると次のようになります。

フック実行タイミング主な用途
useInsertionEffectすべてのレイアウトエフェクトより前(DOM の更新前に走ることも後に走ることもある)<style> タグの挿入
useLayoutEffectDOM の更新後、ブラウザが描画する前レイアウトの計測と、それに応じた同期的な位置調整
useEffectブラウザが描画した後データ取得、購読、ログ送信など

表の1行目で注意したいのは、「DOM 更新前に実行される」と言い切れるわけではない点です。公式ドキュメントの注意書きでは「useInsertionEffect は DOM の更新前に実行される場合も後に実行される場合もあります。タイミングに関わらず、DOM が更新されていることを前提としてはいけません」とされています。保証されているのは「レイアウトエフェクトよりは前」という順序関係だけなので、この中で DOM の状態を読むコードは書けません。

基本の書き方

シグネチャは useInsertionEffect(setup, dependencies?) で、useEffect とまったく同じ形です。第1引数にセットアップ関数を渡し、必要ならクリーンアップ関数を返します。第2引数の依存配列を省略すると毎回のレンダリングで実行され、空配列 [] を渡すとマウント時だけ実行されます。戻り値はありません(undefined)。

Basic.tsx
import { useInsertionEffect } from 'react';

function Box() {
  useInsertionEffect(() => {
    // スタイルを挿入する。DOM の状態を読む処理は書かない
    const style = document.createElement('style');
    style.textContent = '.box { color: crimson; }';
    document.head.appendChild(style);

    // クリーンアップで挿入したものを片付ける
    return () => {
      style.remove();
    };
  }, []);

  return <div className="box">テキスト</div>;
}

クリーンアップの挙動には useEffect との違いがあります。ほかのエフェクトは「全コンポーネントのクリーンアップ → 全コンポーネントのセットアップ」という順で動きますが、useInsertionEffectコンポーネントごとにクリーンアップとセットアップをまとめて実行します。つまり両者が交互(インターリーブ)に走るということです。ライブラリ側でスタイルの参照カウントを管理するときなどは、この順序を前提に実装する必要があります。

ルールを重複なく挿入する簡易 CSS-in-JS の実装例

実際にライブラリを書くつもりで、CSS の宣言を受け取ってクラス名を返す useCss を作ってみます。同じスタイルを使うコンポーネントが複数あっても <style> タグが増え続けないよう、挿入済みのクラス名を Set で覚えておくのがポイントです。

useCss.ts
import { useInsertionEffect } from 'react';

// 挿入済みのクラス名を記録しておき、同じルールを二重に入れないようにする
const inserted = new Set<string>();

// 宣言の内容からクラス名を作る(簡易的なハッシュ)
function toClassName(declarations: string): string {
  let hash = 0;
  for (let i = 0; i < declarations.length; i++) {
    hash = (hash * 31 + declarations.charCodeAt(i)) | 0;
  }
  return `css-${(hash >>> 0).toString(36)}`;
}

export function useCss(declarations: string): string {
  const className = toClassName(declarations);

  useInsertionEffect(() => {
    // すでに挿入済みなら何もしない
    if (inserted.has(className)) return;
    inserted.add(className);

    const style = document.createElement('style');
    style.textContent = `.${className} { ${declarations} }`;
    document.head.appendChild(style);
  }, [className, declarations]);

  return className;
}

クラス名はレンダリング中に計算して即座に返し、実際の <style> の挿入だけを useInsertionEffect に任せているのがこの設計の要点です。呼び出し側は普通のフックとして使えます。

App.tsx
import type { ReactNode } from 'react';
import { useCss } from './useCss';

function Button({ color, children }: { color: string; children: ReactNode }) {
  const className = useCss(`
    padding: 8px 16px;
    border: none;
    border-radius: 4px;
    color: #fff;
    background: ${color};
  `);

  return <button className={className}>{children}</button>;
}

export default function App() {
  return (
    <div>
      <Button color="#0d6efd">送信</Button>
      <Button color="#198754">保存</Button>
      {/* 同じ色なら同じクラス名になり、style タグは追加されない */}
      <Button color="#0d6efd">下書き保存</Button>
    </div>
  );
}

同じ color を渡した3つ目のボタンは、生成されるクラス名が1つ目と同一になるため inserted に引っかかり、<style> タグは増えません。実行時挿入は再計算のコストが高いので、ライブラリを書く場合はこうした重複排除が欠かせません。

この中でできないこと

ref はまだアタッチされていない

useInsertionEffect が実行される時点で、ref にはまだ DOM ノードが入っていません。ref.currentnull のままなので、要素を掴んで何かする処理は書けません。DOM ノードを触りたい場合は useLayoutEffectuseEffect を使います。

state を更新できない

この中から setState を呼ぶことはできません。レンダリングのコミット処理の最中に割り込む位置にあるフックなので、そこから新たな更新を発生させる余地がないためです。挿入したスタイルの情報を React の状態として持ちたくなったら、設計そのものを見直したほうがよいサインだと考えてください。

サーバーサイドでは実行されない

エフェクトはクライアントでのみ実行され、サーバーレンダリング中には実行されません。Next.js のようにサーバーで HTML を生成する環境では、useInsertionEffect に書いたスタイル挿入は初期 HTML に一切反映されないということです。そのままではスタイルの当たっていない状態が一瞬表示されてしまいます。

そのため SSR に対応する CSS-in-JS ライブラリは、サーバー側ではレンダリング中に必要なルールを集めておき、それを HTML の <head> に埋め込むという別経路を用意します。公式ドキュメントでも、レンダリング中にルールを収集する形が例として示されています。

useCss.ts(SSR 対応部分)
// サーバー側で収集したルールを入れておく場所
export const collectedRules = new Set<string>();

export function useCss(declarations: string): string {
  const className = toClassName(declarations);

  // サーバーでは挿入できないので、集めるだけにしておく
  if (typeof window === 'undefined') {
    collectedRules.add(`.${className} { ${declarations} }`);
  }

  useInsertionEffect(() => {
    // ここはクライアントでのみ実行される
    if (inserted.has(className)) return;
    inserted.add(className);

    const style = document.createElement('style');
    style.textContent = `.${className} { ${declarations} }`;
    document.head.appendChild(style);
  }, [className, declarations]);

  return className;
}

集めた collectedRules は、HTML を組み立てる側で <style> タグとして出力します。クライアント側では inserted の初期値にサーバーで出力済みのクラス名を入れておけば、同じルールの二重挿入も防げます。

アプリ開発では useEffect と useLayoutEffect で足りる

ここまで見てきたとおり、useInsertionEffect は「レイアウトエフェクトより前に <style> を挿入する」という一点のためだけに存在します。DOM も ref も state も触れないので、それ以外の用途にはそもそも使えません。判断の基準はとてもシンプルで、自分が CSS-in-JS ライブラリを実装していて、実行時に <style> タグを挿入する必要がある場合だけ使います。

アプリ側のコードでは、ブラウザが描画する前に要素のサイズや位置を測って調整したいなら useLayoutEffect、それ以外のデータ取得や購読、ログ送信などはすべて useEffect です。「なんとなく一番早く実行されるフックを使えば速そう」という理由で useInsertionEffect を選ぶと、ref も state も使えないぶん実装が破綻するだけで、速くもなりません。

そして前提として、React 公式は実行時の <style> 挿入そのものを推奨していません。スタイリングの方法をこれから決めるのであれば、静的な CSS ファイルへ切り出すビルド時抽出型のライブラリか、動的な値はインラインスタイルで指定する方針を検討するほうが、パフォーマンス上は有利です。

まとめ

useInsertionEffect(setup, dependencies?) は、CSS-in-JS ライブラリが実行時に <style> タグを挿入するために用意された、React 18 以降のフックです。すべてのレイアウトエフェクトより前に実行されるため、useLayoutEffect でレイアウトを計測する処理が動く前にスタイルを入れ終えられます。ただし DOM の更新前後どちらで走るかは保証されず、ref はまだアタッチされておらず、state も更新できません。サーバーレンダリング中には実行されないため、SSR 対応ではレンダリング中にルールを収集して HTML へ埋め込む別経路が必要です。通常のアプリ開発でこのフックを使う場面はなく、描画前の同期的な調整は useLayoutEffect、それ以外は useEffect で十分です。

参考ページ