useSyncExternalStoreで、Reactの外にあるストアを安全に読む方法

​​React​startTransition,TypeScript,useEffect,useSyncExternalStore,サーバーレンダリング

React の外にあるストアの値を useSyncExternalStore で読むと、描画の途中でストアが変わっても、画面のコンポーネントはすべて同じ時点の値を表示します。useState と useEffect で購読する書き方では、startTransition の描画中にストアが変わると、コンポーネントごとに違う値が画面に残りました。

筆者は検証環境で、この2つの書き方を同じ条件で動かして比べました。あわせて、getSnapshot・subscribe・getServerSnapshot の3つの引数で起きやすい失敗も、実際に出るエラーや購読の回数を見て確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 6.0.3、esbuild 0.28.2、jsdom 29.1.1 です。.ts / .tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました(React は開発用ビルドです)。GIF の撮影には、Playwright 1.56.1 の Chromium と、React の本番用ビルドを使いました。

検証に使ったストアと、2つの読み方

useSyncExternalStore は、React の外にあるストアを購読するためのフックです。公式リファレンスの、外部のストアを購読する使い方の説明では、React の外で state を持つ状態管理ライブラリや、値が変わるとイベントで知らせるブラウザの API を、外部のストアの例に挙げています。

検証では、数値を1つだけ持つストアを作りました。subscribe は、ストアが変わったときに呼ぶ関数を登録し、登録を外す関数を返します。stats は、登録と解除の回数を数えるためのものです。

ts
// React の外にある、値を1つだけ持つストア
type Listener = () => void;

let value = 0;
const listeners = new Set<Listener>();
export const stats = { subscribes: 0, unsubscribes: 0 };

export const store = {
  get: (): number => value,
  set: (next: number) => {
    value = next;
    listeners.forEach((l) => l());
  },
  subscribe: (listener: Listener) => {
    stats.subscribes++;
    listeners.add(listener);
    return () => {
      stats.unsubscribes++;
      listeners.delete(listener);
    };
  },
};

このストアを、2通りのフックで読みます。①は、useState に値を写し、useEffect で購読する書き方です。

ts
import { useEffect, useState } from 'react';
import { store } from './store';

// ① useState と useEffect で購読する
export function useStoreValue() {
  const [value, setValue] = useState(store.get());
  useEffect(() => {
    return store.subscribe(() => setValue(store.get()));
  }, []);
  return value;
}

②は、useSyncExternalStore に、購読する関数と、今の値を返す関数を渡す書き方です。

ts
import { useSyncExternalStore } from 'react';
import { store } from './store';

// ② useSyncExternalStore で購読する
export function useStoreValue() {
  return useSyncExternalStore(store.subscribe, store.get);
}

Transition の描画中にストアが変わると、①は値がばらばらになることがある

比べるために、ストアの値を表示する Item を30個並べた一覧を作りました。Item は、描画に時間のかかるコンポーネントの代わりに、1個あたり3ミリ秒かけて描画します(slow(3))。renderLog には、Item が描画されるたびに番号を記録します。次のコードは、検証に使ったコードからの抜粋です(このあとの抜粋も含め、import などは省いています)。

tsx
function Item({ i }: { i: number }) {
  const value = useStoreValue();
  slow(3);
  renderLog.push(i);
  return <li>{value}</li>;
}

この一覧を startTransition の中で描画し始め、描画の途中で、ストアの値を2ミリ秒ごとに +1 しました。+1 は10回で止めるので、ストアの最後の値は 10 です。描画が終わってから200ミリ秒待ち、画面の値をもう一度読みました。どちらの書き方も3回ずつ動かし、3回とも同じ傾向になりました。そのうち1回の出力です。

text
$ node tear.mjs ./out/ListEffect.mjs
./out/ListEffect.mjs
  最初に画面に出た値: 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
  200ms後の値:       2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
  ストアの値=10 表示の種類=9 Item の描画回数=30

$ node tear.mjs ./out/ListSync.mjs
./out/ListSync.mjs
  最初に画面に出た値: 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
  200ms後の値:       10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
  ストアの値=10 表示の種類=1 Item の描画回数=60

①では、先に描画された Item ほど古い値を表示しました。1つの画面に9種類の値が並んでいます。しかも200ミリ秒たっても直りません。①は、描画が終わったあとの useEffect で初めて購読するため、描画の途中で起きた変更を受け取れないからです。

②では、30個すべてが最後の値 10 を表示しました。Item の描画回数は60回で、30個の描画を1回やり直したことが分かります。公式リファレンスの注意事項によると、React は Transition の描画を画面に反映する直前に、getSnapshot をもう一度呼びます。最初に呼んだときと値が違えば、描画を最初からやり直します。このやり直しは、ブロッキングの更新として行われます。画面のすべてのコンポーネントが、ストアの同じ時点の値を表示するようにするためです。やり直しがブロッキングで行われたことは、検証では確かめていません。

次の GIF は、2つの書き方を Chromium の画面に縦に並べ、同じ操作をした様子です。

「表示する」を押すと、上の①と下の②の一覧を1つずつ順に、startTransition で描画します。描画の途中で、それぞれのパネルのストアを5ミリ秒ごとに +1 します(10回で止めます)。セルの枠が水色ならストアと同じ値、灰色ならストアと違う値です。押した直後は、値が変わった「ストアの値」と「表示中の値」の欄がピンクの枠と文字になります。

1回目のクリックのあと、①は0から10まで11種類の値が並び、30個のうち19個のセルが灰色になります。②は30個とも 10 です。2回目に「ストアを +1」を押すと、①も②も 11 にそろいます。①でも、描画が終わって購読が始まったあとの変更は受け取れるためです。

2つの書き方を縦に並べ、「表示する」を押すと①の一覧は0から10の値が混ざり、②は10にそろう。「ストアを +1」を押すと両方とも11にそろう
GIFアニメーション:「表示する」で一覧を描画している途中にストアが変わると、①は11種類の値が混ざり、②はすべて10になる。次の「ストアを +1」で両方とも11にそろう
ポイント

useState と useEffect で外部のストアを写す書き方は、描画の途中の変更を取りこぼし、コンポーネントごとに違う値を表示することがあります。useSyncExternalStore に置き換えると、React が描画の終わりに値を確かめ直すため、画面の値がそろいます。

なお、Chromium では、ストアの変更に setInterval を使うと、①でも30個の値がそろいました(3回試して3回とも)。そのため GIF では、ストアの変更を MessageChannel で描画の合間に割り込ませています。外部の変更が描画の合間に入るかどうかは、変更の起こし方によって変わりました。

getSnapshot が毎回新しいオブジェクトを返すと、無限ループになる

getSnapshot は、ストアが変わっていない間は、何度呼ばれても同じ値を返す必要があります。公式リファレンスの引数の説明によると、React は getSnapshot の戻り値を Object.is で前回と比べ、違えば描画し直します。

次の getSnapshotUncached は、呼ぶたびに新しいオブジェクトを作って返します。中の値が同じでも、Object.is では別のものになります。

tsx
// ❌ 呼ぶたびに新しいオブジェクトを返す
function getSnapshotUncached() {
  return { value: store.get() };
}

export function Uncached() {
  const snap = useSyncExternalStore(store.subscribe, getSnapshotUncached);
  return <p>{snap.value}</p>;
}

これを描画すると、開発用ビルドの React が警告を出し、そのあと更新の回数の上限を超えたというエラーで止まりました。画面には何も出ていません。

text
$ node snapshot.mjs Uncached
Uncached: innerHTML=""
  console.error: The result of getSnapshot should be cached to avoid an infinite loop
  act から投げられたエラー: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.

公式リファレンスの、このエラーへの対処の説明によると、ストアのデータがイミュータブル(書き換えずに新しく作る)なら、そのデータをそのまま返して構いません。データを直接書き換えるストアなら、前回作ったスナップショットを覚えておき、データが変わったときだけ新しく作ります。検証では、次のように直すとエラーが出なくなりました。

tsx
// ✅ ストアの値が変わったときだけ、新しいオブジェクトを作る
let lastValue: number | undefined;
let lastSnapshot: { value: number } | undefined;
function getSnapshotCached() {
  const value = store.get();
  if (lastSnapshot === undefined || value !== lastValue) {
    lastValue = value;
    lastSnapshot = { value };
  }
  return lastSnapshot;
}
text
$ node snapshot.mjs Cached
Cached: innerHTML="<p>0</p>"
  エラーなし

subscribe をコンポーネントの中で作ると、描画のたびに購読し直す

subscribe を関数コンポーネントの中で作ると、描画のたびに別の関数になります。公式リファレンスの注意事項によると、描画し直したときに前回と違う subscribe が渡されると、React はストアを購読し直します。

検証では、subscribe をコンポーネントの中で作る版(Inside)と、コンポーネントの外で作った store.subscribe をそのまま渡す版(Outside)を用意しました。どちらも、ストアとは関係のない state をボタンで変えて、3回描画し直しました。

tsx
// ❌ subscribe をコンポーネントの中で作る
export function Inside() {
  const [, setTick] = useState(0);
  function subscribe(callback: () => void) {
    return store.subscribe(callback);
  }
  const value = useSyncExternalStore(subscribe, store.get);
  return <button id="rerender" onClick={() => setTick((t) => t + 1)}>{value}</button>;
}
text
$ node subscribe.mjs Inside
Inside: 最初の描画のあと subscribe=1 unsubscribe=0
Inside: 3回描画し直したあと subscribe=4 unsubscribe=3

$ node subscribe.mjs Outside
Outside: 最初の描画のあと subscribe=1 unsubscribe=0
Outside: 3回描画し直したあと subscribe=1 unsubscribe=0

Inside は、描画し直すたびに購読を外して登録し直し、3回で解除と登録が3回ずつ増えました。Outside は、最初の1回だけです。

公式リファレンスの、描画のたびに subscribe が呼ばれるときの対処では、購読し直すことが性能の問題になる場合の直し方として、subscribe をコンポーネントの外に出す方法を挙げています。props に応じて購読先を変える必要があるなら、useCallback で包み、その値が変わったときだけ購読し直す方法もあります。useCallback で包む方法は、検証では試していません。

サーバーで描画するなら getServerSnapshot を渡す

サーバーで HTML を作るアプリでは、3つ目の引数 getServerSnapshot が必要です。公式リファレンスの引数の説明には、省くとサーバーでの描画がエラーになると書かれています。

検証では、getServerSnapshot を渡さない版と、() => 0 を渡す版を renderToString で描画しました。渡す版は、そのあと jsdom の上で hydrateRoot によりハイドレーションしました。クライアント側のストアには、ハイドレーションの前に 5 を入れています。seen には、クライアントで描画するたびに使った値を記録します。

tsx
// サーバーとハイドレーション中は 0 を使う
export function WithServerSnapshot() {
  const value = useSyncExternalStore(store.subscribe, store.get, () => 0);
  seen.push(value);
  return <p>値: {value}</p>;
}
text
$ node ssr.mjs
--- getServerSnapshot なし ---
エラー: Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.
--- getServerSnapshot あり ---
サーバーの HTML: <p>値: <!-- -->0</p>
クライアントで描画した値の順: 0 → 5
ハイドレーション後の HTML: <p>値: <!-- -->5</p>
ハイドレーションのエラー: なし

渡さない版は、renderToString がエラーを投げました。渡す版は、サーバーの HTML が 0 になりました。クライアントでは、ハイドレーションの描画で 0 を使い、そのあとストアの値 5 で描画し直しました。サーバーの HTML と食い違わないので、ハイドレーションのエラーは出ていません。

公式リファレンスの、サーバーでの描画に対応させる方法の説明によると、getServerSnapshot はサーバーでの HTML の生成と、クライアントでのハイドレーションの2つの場面でだけ呼ばれます。クライアントの最初の描画では、サーバーと同じ値を返す必要があります。サーバーのストアの中身を使うなら、<script> タグでクライアントに渡す方法などが紹介されています。サーバーにとって意味のある値がないときは、getServerSnapshot を省き、その部分をクライアントだけで描画させる方法もあります。

安全につなぐための確認点

ここまでの結果をまとめると、外部のストアとつなぐときは、次の4点を確かめておくと安全です。

  • 外部のストアの値は、useState と useEffect で写さず、useSyncExternalStore で読む
  • getSnapshot は、ストアが変わっていなければ同じ値(同じオブジェクト)を返す
  • subscribe はコンポーネントの外で作るか、useCallback で包む
  • サーバーで描画するなら、サーバーとクライアントで同じ値を返す getServerSnapshot を渡す

公式リファレンスの注意事項には、もう1つ、useSyncExternalStore が返す値に応じて描画をサスペンドさせない、という注意もあります。ストアの変更は Transition として扱えません。そのため、サスペンドすると近くの Suspense のフォールバックが、表示済みの内容と入れ替わってしまいます。この点は、検証では試していません。

​Posted by 86