useSyncExternalStoreとは、Reactの外にある値(ブラウザのAPIや自作のストアなど)を購読し、値が変わったらコンポーネントを再描画するためのフックです。 「変更を知らせてもらう関数(subscribe)」と「今の値を返す関数(getSnapshot)」の2つを渡すだけで、画面幅やオンライン状態、localStorage の値を state のように読めます。

この記事では、画面幅・オンライン状態・localStorage・メディアクエリ(matchMedia)・自作の小さなストアの5つを、そのまま貼って使えるカスタムフックとして載せます。すべてブラウザで動かし、画面幅を変える、回線を切る、別のタブで値を変える、といった操作で表示がどう変わったかを記録しました。getSnapshot が毎回新しいオブジェクトを返したときのエラーと無限ループの再現、useEffect と useState で書いた場合との違い、サーバーで描画するときに第3引数がない場合のエラー文も載せています。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / React・react-dom 19.3.0 / TypeScript 7.0.2 / esbuild 0.28.2 でビルド(開発ビルド)/ Playwright 1.56.0 の Chromium 141 で実行。

useSyncExternalStoreの早見表

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
引数 役割 書くときの決まり
subscribe(callback) ストアの変更を購読する。変更があったら callback を呼ぶ 購読を解除する関数を return する。コンポーネントの外で定義する(中で作るなら useCallback)
getSnapshot() 今の値を返す ストアが変わっていない間は、何度呼んでも同じ値を返す(Object.is で比べられる)
getServerSnapshot()(省略可) サーバーでの描画と、ハイドレーション時に使う値を返す サーバーとブラウザで同じ値にする。省略するとサーバーでの描画でエラーになる
戻り値 今の値(スナップショット) そのまま JSX で使える

この記事で載せる5つのフックと、それぞれが使うブラウザの機能です。

フック 購読するもの(subscribe) 値(getSnapshot) サーバーでの値
useWindowWidth() resize イベント window.innerWidth 決めておいた幅(例:1024)
useOnlineStatus() online / offline イベント navigator.onLine true
useLocalStorage(key, 初期値) storage イベント+自前のイベント localStorage.getItem(key) 初期値
useMediaQuery(query) matchMedia(query) の change イベント matchMedia(query).matches false(引数で変更可)
createStore(初期値) の useStore() 自作ストアの購読者リスト ストアが持つ値 初期値

つまずきやすい点の要約です。

症状 原因 直し方
「The result of getSnapshot should be cached to avoid an infinite loop」 getSnapshot が毎回新しいオブジェクトや配列を返している 文字列や数値を返す、または直前の結果を覚えておいて同じものを返す
再描画のたびに購読し直している subscribe をコンポーネントの中で定義している コンポーネントの外に出す、または useCallback
サーバーで「Missing getServerSnapshot…」 第3引数がない getServerSnapshot を渡す

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React useSyncExternalStoreの使い方 外部ストア購読の例」

準備:動かすための最小構成

mkdir store-demo && cd store-demo
npm init -y
npm i react react-dom
npm i -D esbuild typescript @types/react @types/react-dom
store-demo/
├─ index.html
└─ src/
   ├─ client.tsx
   ├─ Demo.tsx
   └─ hooks/
      ├─ useWindowWidth.ts
      ├─ useOnlineStatus.ts
      ├─ useLocalStorage.ts
      ├─ useMediaQuery.ts
      └─ createStore.ts
<!doctype html>
<meta charset="utf-8">
<div id="root"></div>
<script src="/client.js"></script>
// src/client.tsx
import { createRoot } from 'react-dom/client'
import { Demo } from './Demo'

createRoot(document.getElementById('root')!).render(<Demo />)
npx esbuild src/client.tsx --bundle --jsx=automatic '--define:process.env.NODE_ENV="development"' --outfile=out/client.js

localStorage はページの配信元ごとに分かれるので、index.html と out/client.js は HTTP サーバー経由で開きます(この記事では Node.js の http モジュールで 127.0.0.1 に配信しました)。

例1:画面幅を読むuseWindowWidth

// src/hooks/useWindowWidth.ts
import { useSyncExternalStore } from 'react'

function subscribe(callback: () => void) {
  window.addEventListener('resize', callback)
  return () => window.removeEventListener('resize', callback)
}
const getSnapshot = () => window.innerWidth
// サーバーには画面がないので、決めておいた幅を返す
const getServerSnapshot = () => 1024

export function useWindowWidth(): number {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}

ポイントは3つです。

  • subscribe は「登録して、解除する関数を返す」だけ。 resize が起きたら React から渡された callback を呼びます
  • getSnapshot は数値を返す。 数値は値が同じなら Object.is で等しいので、そのまま返せます
  • 3つの関数はコンポーネントの外に置く。 再描画のたびに作り直されないので、購読し直しが起きません

例2:オンライン状態を読むuseOnlineStatus

// src/hooks/useOnlineStatus.ts
import { useSyncExternalStore } from 'react'

function subscribe(callback: () => void) {
  window.addEventListener('online', callback)
  window.addEventListener('offline', callback)
  return () => {
    window.removeEventListener('online', callback)
    window.removeEventListener('offline', callback)
  }
}
const getSnapshot = () => navigator.onLine
// サーバーでは「オンライン」として描画する
const getServerSnapshot = () => true

export function useOnlineStatus(): boolean {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}

イベントが2種類あるときは、両方に同じ callback を登録し、解除も両方行います。

例3:localStorageを読み書きするuseLocalStorage

// src/hooks/useLocalStorage.ts
import { useCallback, useSyncExternalStore } from 'react'

// 同じタブの中での変更を知らせるための、自前のイベント名
const EVENT = 'local-storage-change'

export function useLocalStorage(key: string, initialValue: string) {
  const subscribe = useCallback(
    (callback: () => void) => {
      // storage イベントは「ほかのタブ」での変更だけを知らせる
      const onStorage = (e: StorageEvent) => {
        if (e.key === key || e.key === null) callback()
      }
      window.addEventListener('storage', onStorage)
      window.addEventListener(EVENT, callback)
      return () => {
        window.removeEventListener('storage', onStorage)
        window.removeEventListener(EVENT, callback)
      }
    },
    [key],
  )
  // getItem は文字列(または null)を返すので、値が同じなら Object.is で等しい
  const getSnapshot = () => localStorage.getItem(key) ?? initialValue
  const getServerSnapshot = () => initialValue

  const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

  const setValue = useCallback(
    (next: string) => {
      localStorage.setItem(key, next)
      // 同じタブには storage イベントが届かないので、自分で知らせる
      window.dispatchEvent(new Event(EVENT))
    },
    [key],
  )
  return [value, setValue] as const
}

localStorage の購読には、ひと工夫が要ります。

変更が起きた場所 知らせる手段
別のタブ(同じ配信元) ブラウザが storage イベントを発生させる
同じタブ storage イベントは届かないので、書き込みのあとに自前のイベントを発生させる

key を引数で受け取るため、subscribe はコンポーネントの外に出せません。そこで useCallback で包み、key が変わったときだけ購読し直すようにしています。

例4:メディアクエリを読むuseMediaQuery

// src/hooks/useMediaQuery.ts
import { useCallback, useSyncExternalStore } from 'react'

export function useMediaQuery(query: string, serverValue = false): boolean {
  // query が変わったときだけ購読し直すよう、useCallback で包む
  const subscribe = useCallback(
    (callback: () => void) => {
      const mql = window.matchMedia(query)
      mql.addEventListener('change', callback)
      return () => mql.removeEventListener('change', callback)
    },
    [query],
  )
  const getSnapshot = () => window.matchMedia(query).matches
  const getServerSnapshot = () => serverValue

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}

useMediaQuery('(max-width: 600px)') のように CSS と同じ条件を書けます。「600px 以下かどうか」のように条件の真偽だけが必要な場面では、幅の数値を読む useWindowWidth より、真偽値をそのまま受け取れる useMediaQuery が合います。

例5:自作の小さなストアcreateStore

React の外に値を置き、複数のコンポーネントから読めるようにする最小のストアです。値・変更・購読の3つだけでできています。

// src/hooks/createStore.ts
import { useSyncExternalStore } from 'react'

type Listener = () => void

// 自作の小さなストア:値・変更・購読の3つだけ
export function createStore<T>(initialState: T) {
  let state = initialState
  const listeners = new Set<Listener>()

  const getState = () => state
  const setState = (next: T | ((prev: T) => T)) => {
    const value = typeof next === 'function' ? (next as (prev: T) => T)(state) : next
    if (Object.is(value, state)) return
    state = value // 書き換えず、新しい値に差し替える
    listeners.forEach((l) => l())
  }
  const subscribe = (listener: Listener) => {
    listeners.add(listener)
    return () => {
      listeners.delete(listener)
    }
  }
  // コンポーネントから読むためのフック
  const useStore = () => useSyncExternalStore(subscribe, getState, () => initialState)

  return { getState, setState, subscribe, useStore }
}

大事なのは、setState で値を「書き換える」のではなく「新しい値に差し替える」ことです。配列なら push せず [...prev, 追加分] で新しい配列を作ります。getState は差し替えが起きるまで同じ配列を返し続けるので、getSnapshot の決まり(変わっていない間は同じ値)を自然に満たせます。

5つのフックをまとめて動かす

// src/Demo.tsx
import { useWindowWidth } from './hooks/useWindowWidth'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { useLocalStorage } from './hooks/useLocalStorage'
import { useMediaQuery } from './hooks/useMediaQuery'
import { createStore } from './hooks/createStore'

type Task = { id: number; title: string }
export const taskStore = createStore<Task[]>([])
let nextId = 1
export const addTask = (title: string) =>
  taskStore.setState((prev) => [...prev, { id: nextId++, title }])

export function Demo() {
  const width = useWindowWidth()
  const online = useOnlineStatus()
  const [theme, setTheme] = useLocalStorage('theme', 'light')
  const isNarrow = useMediaQuery('(max-width: 600px)')
  const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
  const tasks = taskStore.useStore()

  return (
    <ul>
      <li id="width">画面幅: {width}</li>
      <li id="online">接続: {online ? 'オンライン' : 'オフライン'}</li>
      <li id="theme">
        テーマ: {theme}{' '}
        <button id="toggle" onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>切り替え</button>
      </li>
      <li id="narrow">600px以下: {String(isNarrow)}</li>
      <li id="dark">ダーク設定: {String(prefersDark)}</li>
      <li id="tasks">
        タスク {tasks.length} 件: {tasks.map((t) => t.title).join('、')}{' '}
        <button id="add" onClick={() => addTask('タスク' + nextId)}>追加</button>
      </li>
    </ul>
  )
}

幅1000pxの画面で開き、順に操作したときの表示です。

初期表示        : 画面幅: 1000 | 接続: オンライン | テーマ: light | 600px以下: false | ダーク設定: false | タスク 0 件:
幅を500pxに     : 画面幅: 500 | 接続: オンライン | テーマ: light | 600px以下: true | ダーク設定: false | タスク 0 件:
オフラインに    : 画面幅: 500 | 接続: オフライン | テーマ: light | 600px以下: true | ダーク設定: false | タスク 0 件:
ダーク設定に    : 画面幅: 500 | 接続: オンライン | テーマ: light | 600px以下: true | ダーク設定: true | タスク 0 件:
テーマ切り替え  : 画面幅: 500 | 接続: オンライン | テーマ: dark | 600px以下: true | ダーク設定: true | タスク 0 件:
タスクを2回追加 : 画面幅: 500 | 接続: オンライン | テーマ: light | 600px以下: true | ダーク設定: true | タスク 2 件: タスク1、タスク2

(「ダーク設定に」の前に回線をオンラインへ戻しています。最後の行でテーマが light なのは、次に示す別タブからの操作のあとに記録したためです。)

どの値も、useState や useEffect を書かずに画面へ反映されました。続けて、同じページを別のタブで開いてテーマを切り替えた結果です。

別タブの初期表示: 画面幅: 1000 | 接続: オンライン | テーマ: dark | …
別タブで切り替え → 別タブ: テーマ: light / 元のタブ: テーマ: light

別タブは localStorage に保存された dark を読んで表示し、そこで light に切り替えると、操作していない元のタブも light に変わりました。 storage イベントの購読が効いています。

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

useSyncExternalStore でいちばん多いつまずきが、getSnapshot でオブジェクトを組み立てて返す書き方です。画面の幅と高さを一度に返そうとした例で再現します。

// src/Loop.tsx
import { useSyncExternalStore } from 'react'

function subscribe(callback: () => void) {
  window.addEventListener('resize', callback)
  return () => window.removeEventListener('resize', callback)
}

// 呼ぶたびに新しいオブジェクトを返している
function getSnapshot() {
  return { width: window.innerWidth, height: window.innerHeight }
}

export function WindowSizeLoop() {
  const size = useSyncExternalStore(subscribe, getSnapshot)
  return <p id="size">{size.width} x {size.height}</p>
}

ブラウザで開いたときのコンソール出力です。

[console.error] The result of getSnapshot should be cached to avoid an infinite loop
[pageerror] 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.
[console.warning] An error occurred in the <WindowSizeLoop> component.
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.
画面: 何も表示されない(root の中身が空)

「The result of getSnapshot should be cached to avoid an infinite loop」に続いて「Maximum update depth exceeded」で止まり、画面には何も表示されませんでした。

理由は比較の方法にあります。React は getSnapshot の結果を Object.is で前回と比べ、違っていれば「ストアが変わった」と判断して再描画します。{ width, height } は中身が同じでも毎回別のオブジェクトなので、常に「変わった」と判定され、再描画が止まらなくなります。

直し方1:直前の結果を覚えておき、同じなら同じものを返す

// src/LoopFixed.tsx
import { useSyncExternalStore } from 'react'

type Size = { width: number; height: number }

function subscribe(callback: () => void) {
  window.addEventListener('resize', callback)
  return () => window.removeEventListener('resize', callback)
}

// 直前に返したオブジェクトを覚えておき、中身が同じなら同じものを返す
let last: Size | null = null
function getSnapshot(): Size {
  const width = window.innerWidth
  const height = window.innerHeight
  if (last === null || last.width !== width || last.height !== height) {
    last = { width, height }
  }
  return last
}
const serverSize: Size = { width: 1024, height: 768 }
const getServerSnapshot = () => serverSize

export function WindowSizeFixed() {
  const size = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
  return <p id="size">{size.width} x {size.height}</p>
}
画面: 1000 x 700
800x600 に変更: 800 x 600

エラーは出ず、サイズを変えると表示も変わりました。getServerSnapshot も同じ理由で、毎回 {...} を作らず、外で作った1つのオブジェクトを返す形にしています。

直し方2:数値や文字列を別々に返す

幅と高さを別々のフックにして、それぞれ数値を返せば、覚えておく処理は不要です。例1の useWindowWidth がこの形です。

localStorageのJSONも同じ理由で止まる

localStorage にオブジェクトを保存したくなったとき、getSnapshot の中で JSON.parse すると同じことが起きます。

// src/Json.tsx
import { useSyncExternalStore } from 'react'
import { useLocalStorageJSON } from './hooks/useLocalStorageJSON'

type Settings = { fontSize: number }

const subscribe = (callback: () => void) => {
  window.addEventListener('storage', callback)
  return () => window.removeEventListener('storage', callback)
}

// getSnapshot の中で JSON.parse している(毎回新しいオブジェクトになる)
export function SettingsParseInSnapshot() {
  const settings = useSyncExternalStore(
    subscribe,
    () => JSON.parse(localStorage.getItem('settings') ?? '{"fontSize":14}') as Settings,
  )
  return <p id="font">文字サイズ: {settings.fontSize}</p>
}

// 文字列で受け取ってから useMemo で JSON.parse する
export function SettingsWithMemo() {
  const [settings, setSettings] = useLocalStorageJSON<Settings>('settings', { fontSize: 14 })
  return (
    <p id="font">
      文字サイズ: {settings.fontSize}{' '}
      <button id="bigger" onClick={() => setSettings({ fontSize: settings.fontSize + 2 })}>大きく</button>
    </p>
  )
}
// src/hooks/useLocalStorageJSON.ts
import { useMemo } from 'react'
import { useLocalStorage } from './useLocalStorage'

// オブジェクトを保存したいとき:スナップショットは文字列のまま受け取り、JSON.parse は useMemo で行う
export function useLocalStorageJSON<T>(key: string, initialValue: T) {
  const [text, setText] = useLocalStorage(key, JSON.stringify(initialValue))
  const value = useMemo(() => JSON.parse(text) as T, [text])
  const setValue = (next: T) => setText(JSON.stringify(next))
  return [value, setValue] as const
}
== SettingsParseInSnapshot
[console.error] The result of getSnapshot should be cached to avoid an infinite loop
[pageerror] Maximum update depth exceeded. …
== SettingsWithMemo
画面: 文字サイズ: 14
「大きく」後: 16 / localStorage: {"fontSize":16}

スナップショットは文字列のまま受け取り、オブジェクトへの変換はそのあとで行うのがコツです。文字列は内容が同じなら等しいと判定されるので、ループになりません。

getSnapshot が返すもの そのまま返せるか
数値・文字列・真偽値 返せる
ストアが保持している配列やオブジェクトそのもの(差し替え方式) 返せる
その場で作った {...} や [...]、JSON.parse の結果、filter / map の結果 直前の結果を覚えておく処理が必要

subscribeをコンポーネントの中で定義すると毎回購読し直す

subscribe をコンポーネントの中で書くと、再描画のたびに別の関数になります。ボタンで再描画を起こし、購読と解除の回数を数えました。

// src/Resubscribe.tsx
import { useState, useSyncExternalStore } from 'react'

const getSnapshot = () => window.innerWidth

// subscribe をコンポーネントの中で定義した例
export function InsideSubscribe() {
  const [count, setCount] = useState(0)
  const subscribe = (callback: () => void) => {
    console.log('subscribe')
    window.addEventListener('resize', callback)
    return () => {
      console.log('unsubscribe')
      window.removeEventListener('resize', callback)
    }
  }
  const width = useSyncExternalStore(subscribe, getSnapshot)
  return <button id="btn" onClick={() => setCount(count + 1)}>{width} / count {count}</button>
}

// subscribe をコンポーネントの外で定義した例
function subscribeOutside(callback: () => void) {
  console.log('subscribe')
  window.addEventListener('resize', callback)
  return () => {
    console.log('unsubscribe')
    window.removeEventListener('resize', callback)
  }
}
export function OutsideSubscribe() {
  const [count, setCount] = useState(0)
  const width = useSyncExternalStore(subscribeOutside, getSnapshot)
  return <button id="btn" onClick={() => setCount(count + 1)}>{width} / count {count}</button>
}
== InsideSubscribe(中で定義)
subscribe
-- クリック 1
unsubscribe
subscribe
-- クリック 2
unsubscribe
subscribe
== OutsideSubscribe(外で定義)
subscribe
-- クリック 1
-- クリック 2

中で定義した版は、クリックで再描画するたびに解除と購読をやり直しています。 外で定義した版は最初の1回だけです。表示される値は同じですが、外に出しておけば購読と解除のやり直しがなくなります。引数に応じて購読先を変える場合は、例3・例4のように useCallback で包みます。

useEffect+useStateで書いた場合との違い

「useEffect で購読して、useState に入れる」書き方でも似たことはできます。違いが出る場面を1つ再現します。画面に出た直後、useEffect が動くより前にストアが更新された場合です。

// src/Compare.tsx
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from 'react'
import { createStore } from './hooks/createStore'

const store = createStore(0)

// useEffect + useState で購読する書き方
function EffectReader() {
  const [value, setValue] = useState(store.getState())
  useEffect(() => store.subscribe(() => setValue(store.getState())), [])
  console.log('EffectReader render: ' + value)
  return <p id="effect">useEffect版: {value}</p>
}

// useSyncExternalStore で購読する書き方
function SyncReader() {
  const value = useSyncExternalStore(store.subscribe, store.getState)
  console.log('SyncReader render: ' + value)
  return <p id="sync">useSyncExternalStore版: {value}</p>
}

// 画面に出た直後(useEffect より前)にストアを更新する部品
function Updater() {
  useLayoutEffect(() => {
    store.setState(1)
  }, [])
  return null
}

export function Compare() {
  return (
    <>
      <EffectReader />
      <SyncReader />
      <Updater />
      <button id="inc" onClick={() => store.setState((n) => n + 1)}>+1</button>
    </>
  )
}
EffectReader render: 0
SyncReader render: 0
SyncReader render: 1
画面: useEffect版: 0 / useSyncExternalStore版: 1
(+1 ボタンを押す)
EffectReader render: 2
SyncReader render: 2
画面: useEffect版: 2 / useSyncExternalStore版: 2

ストアの値は 1 になっているのに、useEffect 版は 0 を表示したままでした。最初の描画で値を読んでから購読を始めるまでの間に起きた変更を、受け取れていないためです。useSyncExternalStore 版は 1 に追いついています。次の変更(+1)が来ると、どちらも 2 になりました。

こうした取りこぼしへの配慮を自分で書く代わりに、React が面倒を見てくれるのが useSyncExternalStore です。

観点 useEffect+useState useSyncExternalStore
書く量 state、購読、解除、初期値の読み込みを自分で書く subscribe と getSnapshot を渡すだけ
描画から購読開始までの間の変更 読み直しを書かないと取りこぼす(上の結果) 追いつく(上の結果)
サーバーでの描画 初期値の扱いを自分で決める getServerSnapshot で明示する
向いている対象 購読以外の副作用全般 React の外にある値の購読

サーバーで描画するとき:第3引数がないとエラーになる

第3引数(getServerSnapshot)を渡していないコンポーネントを、renderToString でサーバー描画してみます。

// src/NoServer.tsx
import { useSyncExternalStore } from 'react'

function subscribe(callback: () => void) {
  window.addEventListener('resize', callback)
  return () => window.removeEventListener('resize', callback)
}

// 第3引数(getServerSnapshot)を渡していない
export function WidthNoServer() {
  const width = useSyncExternalStore(subscribe, () => window.innerWidth)
  return <p id="width">画面幅: {width}</p>
}
// src/ssr.tsx
import { renderToString } from 'react-dom/server'
import { Demo } from './Demo'
import { WidthNoServer } from './NoServer'

console.log('--- Demo(getServerSnapshot あり)')
console.log(renderToString(<Demo />).replace(/></g, '>\n<'))
console.log('--- WidthNoServer(getServerSnapshot なし)')
try {
  console.log(renderToString(<WidthNoServer />))
} catch (e) {
  console.log('エラー: ' + (e as Error).message)
}
npx esbuild src/ssr.tsx --bundle --platform=node --jsx=automatic --outfile=out/ssr.cjs
node out/ssr.cjs
--- Demo(getServerSnapshot あり)
<ul>
<li id="width">画面幅: <!-- -->1024</li>
<li id="online">接続: <!-- -->オンライン</li>
<li id="theme">テーマ: <!-- -->light<!-- --> <button id="toggle">切り替え</button>
</li>
<li id="narrow">600px以下: <!-- -->false</li>
<li id="dark">ダーク設定: <!-- -->false</li>
<li id="tasks">タスク <!-- -->0<!-- --> 件: <!-- --> <button id="add">追加</button>
</li>
</ul>
--- WidthNoServer(getServerSnapshot なし)
エラー: Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.

第3引数がないと「Missing getServerSnapshot, which is required for server-rendered content.」というエラーになりました。 5つのフックを使った Demo は、それぞれの getServerSnapshot が返した値(幅 1024、オンライン、light、false)で HTML になっています。同じ WidthNoServer を、サーバーを使わずブラウザだけで描画(createRoot)した場合は「画面幅: 1000」と表示され、エラーは出ませんでした。

ハイドレーション後に本来の値へ更新される

サーバーが出力した Demo の HTML を、幅500pxのブラウザで開いてハイドレーションした結果です。

サーバーのHTMLのみ: 画面幅: 1024 | 接続: オンライン | テーマ: light | 600px以下: false | ダーク設定: false | タスク 0 件:
ハイドレーション後: 画面幅: 500 | 接続: オンライン | テーマ: light | 600px以下: true | ダーク設定: false | タスク 0 件:

ハイドレーション中は getServerSnapshot の値(1024)で照合するので、不一致の警告は出ませんでした。そのあと getSnapshot の値(500)に更新されています。

getServerSnapshot の決め方 例
サーバーとブラウザで必ず同じ値を返す 定数、またはサーバーから HTML と一緒に渡した値
ブラウザでしか決まらない値は、無難な初期値にする 画面幅は代表的な幅、オンライン状態は true、localStorage は初期値
オブジェクトを返すなら、外で作った1つを返す const serverSize = {...} を返す(毎回作らない)

使う場面の判断表

読みたい値 選ぶもの
コンポーネントの中だけで使う値 useState / useReducer
親子の間で渡す値 props、Context
ブラウザの API が持つ値(画面幅、オンライン状態、メディアクエリ、localStorage) useSyncExternalStore(この記事のフック)
React の外にある自作のストアや、購読の仕組みを持つライブラリの値 useSyncExternalStore
サーバーから取得するデータ データ取得の仕組み(フレームワークやデータ取得ライブラリ)を使う

公式ドキュメントには、ほかにも次の注意点が書かれています。

注意点 内容
スナップショットは不変にする ストアの中身を書き換える方式なら、変更があったときに新しいスナップショットを作り、なければ前回のものを返す
トランジション中にストアが変わった場合 React は DOM に反映する直前にもう一度 getSnapshot を呼び、値が違えば更新を最初からやり直して、画面全体が同じ版のストアを映すようにする
ストアの値をもとに Suspense で待たせる使い方 外部ストアの変更はトランジションとして扱えないため、推奨されていない

よくある質問

Q. useSyncExternalStore は、状態管理ライブラリを作る人のためのフックですか? A. アプリのコードで使う場面もあります。画面幅、オンライン状態、メディアクエリ、localStorage のように、ブラウザが持っていて時間とともに変わる値を読むときに、この記事のような短いカスタムフックにして使えます。

Q. getSnapshot で配列やオブジェクトを返したいときはどうしますか? A. ストアが保持している配列やオブジェクトをそのまま返し、変更のときは新しいものに差し替えます(例5の createStore)。その場で組み立てる必要があるときは、直前の結果を覚えておき、中身が同じなら同じものを返します。

Q. サーバーで描画しないアプリでも getServerSnapshot は必要ですか? A. ブラウザだけで描画するなら、省略しても動きました(この記事の WidthNoServer を createRoot で描画した結果)。あとからサーバーでの描画を取り入れる可能性があるカスタムフックでは、最初から渡しておくと移行しやすくなります。

Q. localStorage の値が同じタブで更新されません。 A. storage イベントは、ほかのタブでの変更を知らせるものです。同じタブで書き込んだときは、例3のように書き込みのあとで自前のイベントを発生させ、subscribe でそのイベントも購読します。

まとめ

  • useSyncExternalStore は、React の外にある値を購読するフック。 subscribe(購読して解除関数を返す)と getSnapshot(今の値を返す)を渡す
  • 画面幅・オンライン状態・localStorage・メディアクエリ・自作ストアは、短いカスタムフックにして使い回せる
  • getSnapshot は、変わっていない間は同じ値を返す。 毎回新しいオブジェクトを返すと「The result of getSnapshot should be cached」に続いて「Maximum update depth exceeded」で止まる
  • オブジェクトが必要なら、直前の結果を覚えておくか、文字列で受け取って useMemo で変換する
  • subscribe はコンポーネントの外に置く。 引数で購読先が変わるなら useCallback
  • サーバーで描画するなら getServerSnapshot を渡す。 ないと「Missing getServerSnapshot…」のエラーになる
  • useEffect+useState と比べて、描画から購読開始までの間の変更にも追いつく

参考・出典

  • React 公式ドキュメント「useSyncExternalStore」 https://react.dev/reference/react/useSyncExternalStore

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React useSyncExternalStoreの使い方 外部ストア購読の例」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?