おう、お前さん、Reactの副作用、特に外部ストアとの連携で頭を悩ませてるんだな? よし、今日はその辺のモヤモヤをスッキリさせてやろう。俺が現場で叩き上げてきた知見を、惜しみなくぶっちゃけるぜ。
useEffect vs useSyncExternalStore: 外部ストアとの付き合い方、どっちが本命?
Reactで外部のデータソース(Redux、Zustand、MobX、あるいは自作のPub/Subパターンとかね)と連携する時、お前さん、きっと `useEffect` を使ってるだろ? `useEffect` は便利だ。コンポーネントのマウント時、アンマウント時、更新時…って、色々なタイミングで処理を挟めるから、一見すると何でもできちまうように見える。
だが、ちょっと待ってほしい。外部ストアとの「購読」という文脈で `useEffect` を使い続けると、思わぬ落とし穴にハマる可能性があるんだ。特に、レンダリングの整合性という観点では、`useEffect` は「次善の策」に過ぎないことが多い。
そこで、今日のお題は `useSyncExternalStore` だ。こいつは、React 18で導入された、まさに外部ストアとの連携のために設計されたフックなんだ。なんで `useEffect` よりも `useSyncExternalStore` を使うべきなのか、そしてそれがどうやってレンダリングの整合性を保つのか、じっくり見ていこう。
`useEffect` の限界:なぜ「購読」には向かないのか
まず、なんで `useEffect` が外部ストアの購読に完璧じゃないのか、その理由を理解することから始めよう。
`useEffect` は、基本的には「副作用」を扱うためのフックだ。コンポーネントのレンダリングが完了した後に実行される。これは、DOMの操作とか、APIのフェッチとか、その辺の処理にはうってつけだ。
でも、外部ストアの購読となると話はちょっと違う。
1. レンダリングのタイミングずれ:
- `useEffect` はレンダリングが終わってから動く。
- 外部ストアの値が更新された。
- コンポーネントは、古い状態で一度レンダリングを完了してしまう。
- その後 `useEffect` が実行され、ストアの更新を検知し、`setState` を呼ぶ。
- 再レンダリングが発生する。
- つまり、ストアの値が更新されてから、画面に反映されるまでに最低でも2回のレンダリングが発生する可能性があるんだ。これは、特に状態更新が頻繁なアプリケーションだと、ちらつき(flickering)やUIの遅延を引き起こす原因になりうる。
2. クリーンアップの難しさ:
- `useEffect` でストアを購読した場合、コンポーネントがアンマウントされる際に、必ず購読を解除(クリーンアップ)する必要がある。これを忘れると、メモリリークや予期せぬバグの原因になる。
- クリーンアップ関数は、`useEffect` のコールバック関数から `return` する形で記述する。
- しかし、このクリーンアップのタイミングが、Reactの内部的なレンダリングサイクルと完全に同期しているとは限らない。複雑なコンポーネントツリーや、同期処理との組み合わせで、予期せぬ競合状態(race condition)が発生するリスクもゼロではない。
`useEffect` で外部ストアを購読する、よくある(そして危険な)コード例
どんな感じか、具体的なコードで見てみよう。これは、ある外部ストア(ここでは `myExternalStore` と仮定)から値を取得して、コンポーネントで表示する例だ。
// 外部ストアの例(実際にはもっと複雑なものになることが多い)
const myExternalStore = {
_value: ‘Initial Value’,
_listeners: new Set(),
getValue: () => myExternalStore._value,
subscribe: (listener) => {
myExternalStore._listeners.add(listener);
return () => { // クリーンアップ関数
myExternalStore._listeners.delete(listener);
};
},
setValue: (newValue) => {
myExternalStore._value = newValue;
// 購読者に通知(実際にはもっと洗練された方法がある)
myExternalStore._listeners.forEach(listener => listener(newValue));
}
};
// コンポーネント側
import React, { useState, useEffect } from ‘react’;
function ExternalStoreSubscriber() {
const [storeValue, setStoreValue] = useState(myExternalStore.getValue());
useEffect(() => {
// 購読を開始
const unsubscribe = myExternalStore.subscribe(newValue => {
console.log(‘Store updated, updating state…’);
setStoreValue(newValue); // ここで状態を更新
});
// コンポーネントがアンマウントされるときに購読を解除
return () => {
console.log(‘Unsubscribing…’);
unsubscribe();
};
}, []); // 依存配列は空。マウント時とアンマウント時にのみ実行
console.log(‘Rendering with value:’, storeValue);
return (
External Store Value: {storeValue}
);
}
export default ExternalStoreSubscriber;
このコード、一見動くように見えるだろ? 実際に動く。
でも、`Update Store` ボタンを押した時のコンソールログを注意深く見てみてくれ。
- `Rendering with value: …` (古い値でレンダリング)
- `Store updated, updating state…` (`useEffect` のコールバックが実行され、`setStoreValue` が呼ばれる)
- `Rendering with value: …` (新しい値で再レンダリング)
ほら、2回レンダリングしてるのがわかるだろ? 頻繁な更新があると、この2回のレンダリングが積み重なって、ユーザー体験を損なう可能性があるんだ。
`useSyncExternalStore` の登場:レンダリングの整合性をどう保つ?
ここで、満を持して `useSyncExternalStore` の出番だ。こいつは、React 18の「Concurrent Features(並行処理機能)」と密接に関連していて、外部ストアとの同期をより効率的かつ安全に行うために設計されている。
`useSyncExternalStore` の基本的な使い方はこんな感じだ。
import { useSyncExternalStore } from ‘react’;
// useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
- `subscribe`: 外部ストアの変更を購読するための関数。変更があったときにコールバック関数を呼び出す。
- `getSnapshot`: 外部ストアの現在のスナップショット(値)を取得するための関数。
- `getServerSnapshot` (オプション): SSR(サーバーサイドレンダリング)時に使われる。
`useSyncExternalStore` のすごいところ
1. レンダリングの整合性(Consistency):
- `useSyncExternalStore` は、レンダリングの最中にストアの更新を検知し、必要であれば即座に再レンダリングをトリガーできる。
- これは、Reactが内部で「ダーティチェック」のような仕組みを使って、ストアの状態とコンポーネントのローカル状態の同期を管理してくれるからだ。
- つまり、ストアの値が更新されたら、その更新された値でコンポーネントがレンダリングされる。レンダリングの遅延やちらつきが大幅に改善されるんだ。
- ブラウザの裏側では、Reactは `requestAnimationFrame` といったブラウザの描画タイミングに合わせた仕組みを使って、効率的に更新をスケジューリングしている。これにより、ユーザーが目にするUIは常に最新の状態に保たれる。
2. 厳密なクリーンアップ:
- `useSyncExternalStore` は、フックのライフサイクルとストアの購読・解除をより厳密に管理してくれる。
- コンポーネントがマウントされるときに購読し、アンマウントされるときに自動的に解除される。開発者がクリーンアップ漏れを心配する必要がほとんどなくなるんだ。
3. React 18の機能との連携:
- `useSyncExternalStore` は、React 18のConcurrent Features(Suspense、Automatic Batchingなど)とシームレスに連携できるように設計されている。これらを活用するアプリケーションでは、パフォーマンス向上の恩恵を最大限に受けられる。
`useSyncExternalStore` を使った、より良いコード例
先ほどの `useEffect` を使った例を、`useSyncExternalStore` で書き換えてみよう。
// 外部ストアの例(上記と同じ)
const myExternalStore = {
_value: ‘Initial Value’,
_listeners: new Set(),
getValue: () => myExternalStore._value,
subscribe: (listener) => {
myExternalStore._listeners.add(listener);
return () => {
myExternalStore._listeners.delete(listener);
};
},
setValue: (newValue) => {
myExternalStore._value = newValue;
myExternalStore._listeners.forEach(listener => listener(newValue));
}
};
// コンポーネント側
import React from ‘react’;
import { useSyncExternalStore } from ‘react’;
function ExternalStoreSubscriberWithHook() {
// useSyncExternalStore を使って外部ストアを購読
const storeValue = useSyncExternalStore(
myExternalStore.subscribe, // 購読関数
myExternalStore.getValue // 値取得関数
// getServerSnapshot は今回は不要
);
console.log(‘Rendering with value:’, storeValue); // このログは1回だけのはず
return (
External Store Value: {storeValue}
);
}
export default ExternalStoreSubscriberWithHook;
どうだ? コードが劇的にシンプルになったのがわかるだろ?
そして、`Update Store` ボタンを押した時のコンソールログをもう一度見てみよう。
- `Rendering with value: …` (新しい値でレンダリング)
ほら、1回のレンダリングで済んでいる! `useEffect` の時のように、古い値で一度レンダリングしてから、状態更新で再レンダリング、という二段階のプロセスがなくなっているんだ。
`useSyncExternalStore` は、`subscribe` 関数を呼び出すことで、Reactは「このコンポーネントは、このストアの値に依存している」ということを把握する。そして、`getSnapshot` 関数で現在の値を取得する。もし、`subscribe` されたコールバックが呼ばれてストアの値が変更されたら、Reactはコンポーネントを即座に、かつ効率的に再レンダリングしてくれる。
どっちを使うべきか? 実践的な判断基準
さて、ここまで `useEffect` と `useSyncExternalStore` の違いを見てきたが、結局どっちを使えばいいんだ?って話になるよな。
- `useSyncExternalStore` を使うべきケース:
- 外部の状態管理ライブラリ(Redux, Zustand, MobXなど)を購読する場合: これらのライブラリは、`useSyncExternalStore` と連携するように設計されていることが多い。公式のフック(`useSelector`, `useStore`など)が内部で `useSyncExternalStore` を利用している場合も多いから、迷ったらそちらを使うのが吉。
- 状態の更新頻度が高く、UIのちらつきや遅延を避けたい場合: パフォーマンスが重要なアプリケーションでは、`useSyncExternalStore` の方が圧倒的に有利。
- React 18以降の機能(Concurrent Features)を最大限に活用したい場合: `useSyncExternalStore` はこれらの機能と連携するように設計されている。
- コードのシンプルさと安全性を追求したい場合: 購読・解除の管理がReactに任せられるのは、開発者体験としても大きい。
- `useEffect` を使う、あるいは検討しても良いケース:
- 一時的な副作用: 外部ストアの購読とは直接関係なく、コンポーネントのマウント時やアンマウント時に一度だけ実行したいDOM操作、タイマー設定、ログ出力などの場合。
- 既存のコードベース: 大規模なアプリケーションで、既に `useEffect` で大量の外部ストア購読が実装されており、リファクタリングのコストが見合わない場合(ただし、パフォーマンス問題が発生しているなら検討の余地あり)。
- React 18未満の環境: 当然だが、`useSyncExternalStore` はReact 18以降でしか使えない。
まとめ:外部ストアとの付き合いは、賢く、効率的に!
`useEffect` は強力なフックだが、外部ストアとの「同期」という文脈では、その実行タイミングの性質上、限界がある。
一方、`useSyncExternalStore` は、React 18で導入された、この課題を解決するために作られたフックだ。レンダリングの整合性を保ち、パフォーマンスを向上させ、コードをシンプルにしてくれる。
お前さんのアプリケーションで、外部ストアからのデータ表示が遅いな、とか、なんかUIがカクつくんだよな、と感じることがあったら、まずは `useEffect` を `useSyncExternalStore` に置き換えられないか検討してみてほしい。きっと、開発体験もユーザー体験も、ぐっと向上するはずだ。
現場でバリバリ開発しているお前さんなら、この違いはすぐに理解できるはずだ。ぜひ、今日の話を参考に、より洗練されたReactアプリケーション開発を進めてくれ! わからねえことがあったら、いつでも聞いてくれよな!

コメント