useSyncExternalStoreで、Reactの外にあるストアを安全に読む方法
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 は、登録と解除の回数を数えるためのものです。
// 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 で購読する書き方です。
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 に、購読する関数と、今の値を返す関数を渡す書き方です。
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 などは省いています)。
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回の出力です。
$ 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 にそろいます。①でも、描画が終わって購読が始まったあとの変更は受け取れるためです。

useState と useEffect で外部のストアを写す書き方は、描画の途中の変更を取りこぼし、コンポーネントごとに違う値を表示することがあります。useSyncExternalStore に置き換えると、React が描画の終わりに値を確かめ直すため、画面の値がそろいます。
なお、Chromium では、ストアの変更に setInterval を使うと、①でも30個の値がそろいました(3回試して3回とも)。そのため GIF では、ストアの変更を MessageChannel で描画の合間に割り込ませています。外部の変更が描画の合間に入るかどうかは、変更の起こし方によって変わりました。
getSnapshot が毎回新しいオブジェクトを返すと、無限ループになる
getSnapshot は、ストアが変わっていない間は、何度呼ばれても同じ値を返す必要があります。公式リファレンスの引数の説明によると、React は getSnapshot の戻り値を Object.is で前回と比べ、違えば描画し直します。
次の getSnapshotUncached は、呼ぶたびに新しいオブジェクトを作って返します。中の値が同じでも、Object.is では別のものになります。
// ❌ 呼ぶたびに新しいオブジェクトを返す
function getSnapshotUncached() {
return { value: store.get() };
}
export function Uncached() {
const snap = useSyncExternalStore(store.subscribe, getSnapshotUncached);
return <p>{snap.value}</p>;
}
これを描画すると、開発用ビルドの React が警告を出し、そのあと更新の回数の上限を超えたというエラーで止まりました。画面には何も出ていません。
$ 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.
公式リファレンスの、このエラーへの対処の説明によると、ストアのデータがイミュータブル(書き換えずに新しく作る)なら、そのデータをそのまま返して構いません。データを直接書き換えるストアなら、前回作ったスナップショットを覚えておき、データが変わったときだけ新しく作ります。検証では、次のように直すとエラーが出なくなりました。
// ✅ ストアの値が変わったときだけ、新しいオブジェクトを作る
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;
}
$ node snapshot.mjs Cached
Cached: innerHTML="<p>0</p>"
エラーなし
subscribe をコンポーネントの中で作ると、描画のたびに購読し直す
subscribe を関数コンポーネントの中で作ると、描画のたびに別の関数になります。公式リファレンスの注意事項によると、描画し直したときに前回と違う subscribe が渡されると、React はストアを購読し直します。
検証では、subscribe をコンポーネントの中で作る版(Inside)と、コンポーネントの外で作った store.subscribe をそのまま渡す版(Outside)を用意しました。どちらも、ストアとは関係のない state をボタンで変えて、3回描画し直しました。
// ❌ 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>;
}
$ 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 には、クライアントで描画するたびに使った値を記録します。
// サーバーとハイドレーション中は 0 を使う
export function WithServerSnapshot() {
const value = useSyncExternalStore(store.subscribe, store.get, () => 0);
seen.push(value);
return <p>値: {value}</p>;
}
$ 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 のフォールバックが、表示済みの内容と入れ替わってしまいます。この点は、検証では試していません。





