React 19のpreloadとpreinitで先に読み込む方法

​​React​CSS,Suspense,TypeScript,サーバーレンダリング

React 19 の react-dom にある preload と preinit を使うと、あとで使う CSS やスクリプト、フォントを、使う時点より前に読み込み始められます。preload はダウンロードだけを先に済ませ、preinit はダウンロードしたあとに実行や適用まで進めます。どちらも、ブラウザではコンポーネントの描画中やイベントハンドラーなど、どこからでも呼べます。また、同じ URL を何度呼んでも1回分の効果になります(画像の preload には例外があります)。

筆者は検証環境で、2つの関数を Chromium とサーバーでの描画(SSR)の両方で動かしました。<head> に入る要素、precedence による CSS の順番、クリックした時点で読み込みを始めたときの時間の差を確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react / @types/react-dom 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。ブラウザは Chromium 141 を、Playwright 1.56.1 で操作しました。.tsx を tsc で型チェックしてから esbuild で変換しています。React は開発用のビルドです。CSS などのファイルは、検証用の小さなサーバーから配りました。

preload と preinit の違い

公式リファレンスの react-dom の API 一覧には、リソースを先に読み込む関数が6つ並んでいます。このうち、ファイルそのものを読み込むのは次の4つです(prefetchDNS と preconnect は、DNS の解決や接続だけを先に済ませる関数です)。

関数 すること 対象
preload ダウンロードだけする CSS、フォント、画像、スクリプトなど
preinit ダウンロードして、実行または適用する スクリプト、CSS
preloadModule ダウンロードだけする ES モジュール
preinitModule ダウンロードして、実行する ES モジュール

この記事で試したのは preload と preinit です。preloadModule と preinitModule は試していません。

preload の引数では、as で種類を指定します。as に書ける値は style、font、image、script など12種類です。preinit の引数の as は script と style だけです。どちらも戻り値はありません。

描画中に preload と preinit を呼ぶ

コンポーネントの中で、先に読み込みたいファイルを指定します。次のコードでは、CSS とフォントとスクリプトを preload し、別のスクリプトを preinit しています。ボタンを押すと描画し直すので、同じ呼び出しが何度も通ります。

tsx
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { preload, preinit } from 'react-dom';

function App() {
  const [count, setCount] = useState(0);
  // 描画のたびに呼ばれるが、同じ href なら1回分の効果になる
  preload('/assets/style.css', { as: 'style' });
  preload('/assets/font.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
  preload('/assets/preloaded.js', { as: 'script' });
  preinit('/assets/run.js', { as: 'script' });
  return (
    <>
      <p className="sample">サンプル</p>
      <button id="rerender" onClick={() => setCount(count + 1)}>
        描画し直す({count})
      </button>
    </>
  );
}

createRoot(document.getElementById('root')!).render(<App />);

style.css は p.sample を緑にする CSS です。preloaded.js と run.js は、実行されたことが分かるように window に値を書き込みます。Chromium で開いてボタンを3回押し、React が <head> に入れた要素を調べました。出力の一部を抜き出して載せます。

text
--- 「描画し直す」を3回押したあと ---
ボタン: 描画し直す(3)
<link rel="preload" href="/assets/style.css" as="style">
<link rel="preload" href="/assets/font.woff2" as="font" crossorigin="" type="font/woff2">
<link rel="preload" href="/assets/preloaded.js" as="script">
<script src="/assets/run.js" async=""></script>
--- サーバーが受けた /assets/ の要求 ---
/assets/style.css
/assets/font.woff2
/assets/preloaded.js
/assets/run.js
--- スクリプトが実行されたか ---
{"runCount":1,"preloadedRan":null}
--- p.sample の色(style.css は preload しただけ) ---
rgb(0, 0, 0)

preload の呼び出しは <link rel="preload"> に、preinit したスクリプトは async 付きの <script> になりました。4回描画しても要素は1つずつで、サーバーへの要求も1回ずつです。preload の注意点にある「同じ href の呼び出しは1回と同じ」という説明のとおりでした。

ダウンロードだけかどうかの違いも出ています。preinit した run.js は実行されて runCount が 1 になりました。preload した preloaded.js は実行されていません。style.css も preload しただけなので適用されず、文字は黒のままです。

preinit で CSS を入れるときは precedence に注意する

preinit のリファレンスによると、preinit で指定した CSS はページ(HTML のドキュメント)に入り、すぐに効きます。検証では <link rel="stylesheet"> として <head> に入りました。このとき precedence で、ほかの CSS との順番を決めます。

preinit の引数の説明では、precedence に書ける値として reset、low、medium、high が挙がっています。そこで、同じ p.sample に赤を付ける high.css(precedence: 'high')と、青を付ける low.css(precedence: 'low')を用意し、呼ぶ順番だけを変えて試しました。出力からは、コンソールの警告の有無を示す行を省いています。

tsx
// ?order=high-first なら high を先に、low-first なら low を先に呼ぶ
const order = new URLSearchParams(location.search).get('order');

function App() {
  if (order === 'high-first') {
    preinit('/assets/high.css', { as: 'style', precedence: 'high' });
    preinit('/assets/low.css', { as: 'style', precedence: 'low' });
  } else {
    preinit('/assets/low.css', { as: 'style', precedence: 'low' });
    preinit('/assets/high.css', { as: 'style', precedence: 'high' });
  }
  return <p className="sample">どちらの色になるか</p>;
}
text
--- order=low-first ---
<link rel="stylesheet" href="/assets/low.css" data-precedence="low">
<link rel="stylesheet" href="/assets/high.css" data-precedence="high">
p.sample の色: rgb(255, 0, 0)
--- order=high-first ---
<link rel="stylesheet" href="/assets/high.css" data-precedence="high">
<link rel="stylesheet" href="/assets/low.css" data-precedence="low">
p.sample の色: rgb(0, 0, 255)

high を先に呼ぶと high.css が前に入り、あとの low.css の青が勝ちました。high という名前だから強くなるわけではありません。これは <link> のリファレンスの precedence の説明と合っています。そこには、React は先に見つけた precedence の値を「低い」、あとから見つけた値を「高い」とみなすと書かれています。

注意

precedence の値の名前は、CSS の優先度を決めません。最初に出てきた順で並びます。上書きしたい CSS があるなら、上書きされる側の precedence が先に使われるようにしてください。

preinit の引数の説明では、CSS のときは precedence が必須とされています。検証環境の React 19.3.1(開発用ビルド)で省いたところ、警告は出ず、data-precedence="default" の <link> が入って CSS は効きました。リファレンスでは必須とされているので、省いたときのこの動きは当てにせず、precedence を書いておくのが安全です。

クリックした時点で preload を始める

preload の使い方の例には、別の画面に移る前のイベントハンドラーで preload を呼ぶ書き方があります。新しい画面の描画を待つより早く、読み込みを始められます。

これを、lazy で後から読み込むウィザードの画面で試しました。ウィザードのコンポーネントは、描画するときに自分の CSS を preinit します。

tsx
// Wizard.tsx
import { preinit } from 'react-dom';

export default function Wizard() {
  preinit('/assets/wizard.css', { as: 'style', precedence: 'default' });
  return <section id="wizard">ウィザードの1ページ目</section>;
}

使う側では、ボタンを押したときに、ウィザードを表示する前に同じ CSS を preload します(?preload=1 のときだけ)。

tsx
const params = new URLSearchParams(location.search);
// ?link=1 のときは、preinit の代わりに <link precedence> を描画する版を使う
const Wizard = params.get('link') === '1' ? lazy(() => import('./WizardLink')) : lazy(() => import('./Wizard'));
// ?preload=1 のときだけ、クリックした時点で CSS の読み込みを始める
const early = params.get('preload') === '1';

function App() {
  const [open, setOpen] = useState(false);
  const onClick = () => {
    if (early) {
      preload('/assets/wizard.css', { as: 'style' });
    }
    setOpen(true);
  };
  return (
    <>
      <button id="start" onClick={onClick}>ウィザードを始める</button>
      {open && (
        <Suspense fallback={<p id="loading">読み込み中…</p>}>
          <Wizard />
        </Suspense>
      )}
    </>
  );
}

違いが見えるように、検証用のサーバーはウィザードのコードを 600 ミリ秒、wizard.css を 300 ミリ秒遅らせて返します。読み込みの時刻はブラウザの Resource Timing の値、画面に入った時刻と色が付いた時刻は、描画のコマごとに色を確かめて記録した値です。どれもクリックした時刻からの差にしました。

text
--- preload=0 ---
Wizard のコード: クリックから 2ms で要求、606ms で届いた
wizard.css: クリックから 611ms で要求、914ms で届いた(要求 1 回)
#wizard が画面に入った: クリックから 623ms(そのときの色 rgb(0, 0, 0))
#wizard に wizard.css の色が付いた: クリックから 923ms
head: <link rel="stylesheet" href="/assets/wizard.css" data-precedence="default">
--- preload=1 ---
Wizard のコード: クリックから 4ms で要求、608ms で届いた
wizard.css: クリックから 2ms で要求、306ms で届いた(要求 1 回)
#wizard が画面に入った: クリックから 623ms(そのときの色 rgb(216, 88, 120))
#wizard に wizard.css の色が付いた: クリックから 623ms
head: <link rel="stylesheet" href="/assets/wizard.css" data-precedence="default"> <link rel="preload" href="/assets/wizard.css" as="style">
上の段(preload なし)は Wizard のコードが届いてから wizard.css を要求し、923ms で色が付く。下の段(クリックで preload)は wizard.css をクリック直後に要求し、623ms で色が付く
クリックからの時間の流れ。クリックで preload すると、色が付くのが 300ms 早くなった

preload しない場合、CSS の要求が始まったのはクリックから 611 ミリ秒後です。ウィザードのコードが届き、preinit が呼ばれてからでした。クリックで preload した場合は、ウィザードのコードと同時に CSS も読み込み始め、コードが届いたときには CSS も届いていました。どちらの場合も CSS の要求は1回だけで、preload で読んだ分を preinit がそのまま使っています。

preinit は CSS の読み込みを待たない

上の結果で、preload しない場合をもう一度見てください。#wizard が画面に入ったのは 623 ミリ秒で、そのときの文字は黒です。色が付く 923 ミリ秒までの 300 ミリ秒、CSS の効いていない画面が見えていました。preinit は読み込みを始めるだけで、コンポーネントの表示を CSS が届くまで止めはしません。

CSS が届くまで表示を待たせたいときは、preinit の代わりに precedence 付きの <link> を描画します。<link> のリファレンスにある、スタイルシートの特別な扱いの説明には、<link> を描画したコンポーネントは CSS の読み込み中にサスペンドすると書かれています。

tsx
// WizardLink.tsx
export default function WizardLink() {
  return (
    <section id="wizard">
      <link rel="stylesheet" href="/assets/wizard.css" precedence="default" />
      ウィザードの1ページ目
    </section>
  );
}

出力からは、head の行を省いています。

text
--- link=1 ---
Wizard のコード: クリックから 2ms で要求、605ms で届いた
wizard.css: クリックから 611ms で要求、914ms で届いた(要求 1 回)
#wizard が画面に入った: クリックから 924ms(そのときの色 rgb(216, 88, 120))
#wizard に wizard.css の色が付いた: クリックから 924ms

#wizard は CSS が届いたあとの 924 ミリ秒に、色が付いた状態で初めて画面に入りました。待つあいだは Suspense の fallback が出るはずですが、表示されたかどうかは確かめていません。この <link> の書き方とクリックでの preload を組み合わせた場合は試していません。

ポイント

preload は読み込みを早く始めるため、preinit は読み込んで効かせるための関数です。どちらも、CSS が届くまで画面の表示を待たせはしません。待たせたいときは precedence 付きの <link> を使います。

サーバーで描画したときの出力

preload の注意点によると、サーバーでの描画では、コンポーネントの描画中(またはそこから始まった非同期の処理の中)に呼んだときだけ効きます。<html> から描画するページの中のコンポーネントで、preload と preinit を呼び、renderToReadableStream の出力を見ました。

tsx
function App() {
  preload('/assets/font.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
  preload('/assets/preloaded.js', { as: 'script' });
  preinit('/assets/style.css', { as: 'style', precedence: 'default' });
  preinit('/assets/run.js', { as: 'script' });
  return <p className="sample">本文</p>;
}

出力は1行ですが、読みやすいように <head> の中を改行して載せます。

html
<!DOCTYPE html><html><head>
<link rel="preload" href="/assets/font.woff2" as="font" crossorigin="" type="font/woff2"/>
<link rel="stylesheet" href="/assets/style.css" data-precedence="default"/>
<script src="/assets/run.js" async=""></script>
<link rel="preload" href="/assets/preloaded.js" as="script"/>
<title>ページ</title></head><body><p class="sample">本文</p></body></html>

<body> の中で呼んだのに、要素はすべて <head> に入りました。並びは呼んだ順ではなく、フォントの preload、CSS、preinit したスクリプト、スクリプトの preload の順です。renderToString でも同じ要素が同じ順で出ました。

描画の外で呼んだ場合も試しました。renderToReadableStream を呼ぶ前と後に preload を呼んだところ、どちらも出力には何も入りませんでした。

html
<!DOCTYPE html><html><head></head><body><p>描画の外で呼ぶ</p></body></html>

試していないこと

  • preloadModule と preinitModule、画像の imageSrcSet を使った preload
  • サーバーでの描画で、描画から始まった非同期の処理の中で呼ぶ場合や、Server Components で呼ぶ場合
  • Next.js などのフレームワークの上での動き。preload のリファレンスには、React を使ったフレームワークはリソースの読み込みを代わりに扱うことが多いので、自分で呼ぶ必要がないこともあると書かれています
  • Chromium 以外のブラウザと、本番用のビルド

​Posted by 86