0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

useSyncExternalStoreで通知だけでは更新されない理由と、SSRの初期snapshotを確認する

0
Posted at

外部ストアが変更を通知しても、useSyncExternalStoreを使った画面が変わらないことがあります。同じsnapshotオブジェクトを直接書き換えると、今回の実験では通知後も画面の値は0のままでした。

このHookを使うときに揃えるのは、購読の通知と、変更を表すsnapshotです。SSRではさらに、サーバーで描いた初期値をクライアントの最初の読み取りへ渡します。

通知した後もsnapshotの参照が同じだった

次のストアは、正しい置き換えと、比較用の直接変更を両方持つ実験用コードです。実アプリ用にmutateを推奨する例ではありません。

export function createCounterStore(initial) {
  let snapshot = initial;
  const listeners = new Set();
  return {
    getSnapshot: () => snapshot,
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    replace(count) {
      snapshot = { count };
      for (const listener of listeners) listener();
    },
    mutate(count) {
      snapshot.count = count;
      for (const listener of listeners) listener();
    },
    listenerCount: () => listeners.size,
  };
}

replaceは新しいオブジェクトを作り、mutateは元のオブジェクトのcountだけを変えます。両方とも登録済みlistenerを呼びます。

画面側では、useSyncExternalStore(store.subscribe, store.getSnapshot)の戻り値からcountを表示しました。

操作 ストア内のcount 画面のcount 記録された表示用コンポーネントの値
初回 0 0 0
同じsnapshotをmutateして通知 1 0 追加の呼び出しなし
新しいsnapshotへreplaceして通知 2 2 2が追加

通知が届いたかだけを調べても、この違いは見つかりません。getSnapshotの戻り値をObject.isで比較したときに変化を表せるかが必要です。公式リファレンスは、変更がない間は同じsnapshotを返し、変更があれば新しい不変のsnapshotを返す契約を説明しています。

直接変更した後は、親の別のstate更新などによる再描画を挟まずに観測しました。別のきっかけで再描画された場合の表示は、この比較では確認していません。

逆に、読むたびに新しいオブジェクトを作るgetSnapshotもこの契約を満たしません。公式資料は無限の更新につながる例として説明していますが、この実験ではそのループを実行していません。変更時に作ったsnapshotを次の変更まで再利用する作りを確認しています。

購読解除までをストア側の契約にする

subscribeはlistenerの追加だけでなく、削除する関数も返します。実験ではmount後の購読数が1、unmount後は0でした。

同じストアのsubscribe関数を毎回使う構成にしています。描画のたびに購読関数を作り直す場合の登録し直しや、複数の購読者を持つ大規模ストアの性能は測っていません。

SSRでは、現在値とは別に初期値を揃える

サーバーではrenderToStringで次のHTMLを生成しました。

<output id="store-count">0</output>

クライアント側のストアの現在値は、意図的に1へ進めています。サーバーで使った初期値{ count: 0 }をwindow.__BOOTSTRAP__へ渡し、hydrationの間だけそれを読みます。

import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { createCounterStore, StoreCounter } from './external-store.mjs';
const bootstrap = window.__BOOTSTRAP__;
const store = createCounterStore({ count: 1 });
const record = { serverSnapshots: [], renders: [], errors: [] };
function getServerSnapshot() {
  record.serverSnapshots.push(bootstrap.count);
  return bootstrap;
}
const root = hydrateRoot(document.getElementById('root'),
  <StoreCounter store={store} getServerSnapshot={getServerSnapshot} rendered={value => record.renders.push(value)} />,
  { onRecoverableError(error) { record.errors.push(error.message); } });
window.readHydration = () => ({ ...record, count: document.getElementById('store-count')?.textContent, subscribers: store.listenerCount() });
window.unmountHydration = () => root.unmount();

recordとrenderedは観測用です。snapshotの読み取りとコンポーネント呼び出しを記録し、onRecoverableErrorで不一致を検出しました。HTMLや初期データは固定した数値だけで、ユーザーデータの埋め込み例ではありません。

クライアントへ渡す初期値 コンポーネントが読んだcountの順 hydrationの不一致
サーバーと同じ0 0 → 1 なし
サーバーと違う2 2 → 1 あり

正しい初期値では、最初にサーバーと同じ値を読み、その後にクライアントストアの現在値へ進みました。最終表示だけを見ると両方とも1なので、不一致を見逃します。ここでは描画途中の呼び出しを記録したのであって、0が何フレーム見えたかは測っていません。

getServerSnapshotを省いて同じコンポーネントをrenderToStringすると、初期snapshotが必要だというエラーがthrowされました。SSRの公式説明では、サーバーとhydration時に同じデータを返すことが必要とされています。

値の保存場所を置き換えるHookではない

この例で値を保存しているのはcreateCounterStoreです。Hookは購読とsnapshotをReactへ接続します。アプリ内部だけで完結するstateを、すべてこのストアへ移す実験ではありません。

検証日は2026年9月11日、React / React DOM 19.3.0、Node.js 24.15.0、esbuild 0.28.2、macOS arm64、Headless Chrome 152です。通知比較はproduction、hydration比較は開発buildで、Strict ModeとReact Compilerは使っていません。サーバー描画はNode.jsのrenderToStringです。並行レンダリング中のtearingやストリーミングSSRは検証範囲に含めていません。

ストアと表示用コンポーネント、SSR入力、ブラウザ結果を残しました。通知、snapshotの参照、サーバー初期値のどこが変わったかを別々に追えます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?