はじめに
React の関数コンポーネントを書いていて、次のような現象に遭遇することがあります。
- 親コンポーネントが再レンダーされるたびに、子へ渡した callback の参照が変わる
- 子の
useEffectがその callback の参照変化を検知して再実行される - Effect 内で親の state を更新して、さらに再レンダーが増える
- WebSocket や polling のような高頻度更新と組み合わさると、関係ないカードまで巻き込まれる
この問題に対して、useCallback や useRef を使って callback 参照の変化を抑える方法は有効です。
ただし、その前にもう一つ重要な確認があります。
その処理は、本当に
useEffectに書くべき処理なのか?
React 公式ドキュメントでも、Effect は基本的に React 外部のシステムと同期するための escape hatch として説明されています。外部システムが関係せず、単に props / state の変化に応じて別の state を更新するだけなら、そもそも Effect が不要な可能性があります。
この記事では、以前の説明を少し整理し直して、次の順番で考えます。
- まず「イベント起因の更新」と「レンダー起因の同期」を分ける
- Effect が不要なケースではイベントハンドラや state のリフトアップで処理する
- Effect が必要なケースでは
useCallback/useRef/useEffectEventを検討する - 最後に
React.memoを使うべき場面を整理する
先に結論
| 判断軸 | 推奨される置き場所 |
|---|---|
| ユーザー入力、クリック、選択変更など、原因が明確な操作 | イベントハンドラ |
| 子の変更を親に通知したい | イベントハンドラ、または state のリフトアップ |
| props / state から表示用の値を計算したい | レンダー中に計算、必要なら useMemo
|
| DOM、WebSocket、timer、外部ライブラリ、ブラウザ API と同期したい | useEffect |
| Effect 内で最新の callback や state を読みたいが、Effect の再実行条件にはしたくない | React 19.2 以降なら useEffectEvent、それ以前なら慎重に ref パターン |
| 子コンポーネントの再レンダーを抑えたい |
React.memo + 安定した props |
つまり、今回のポイントはこうです。
useEffectの暴発対策をする前に、そもそもその処理をuseEffectに置く必要があるかを確認する。
元の問題:callback の参照が変わると Effect が再実行される
JavaScript の関数はオブジェクトです。
そのため、コンポーネント内で関数を定義すると、レンダーのたびに新しい関数オブジェクトが作られます。
function Parent() {
const handleChange = (value: string) => {
console.log(value)
}
return <Child onChange={handleChange} />
}
Parent が再レンダーされるたびに、handleChange は見た目のコードが同じでも別の参照になります。
子側でこの callback を useEffect の依存配列に入れると、callback の参照変化によって Effect が再実行されます。
function Child({ onChange }: { onChange: (value: string) => void }) {
useEffect(() => {
onChange('hello')
}, [onChange])
return null
}
この構造で Effect 内から親の state を更新すると、次のようなループに近い構造になります。
ここだけを見ると、useCallback で親の callback 参照を安定させるのは自然な対策です。
ただし:この例は Effect が不要かもしれない
ここが今回の指摘の中心です。
子コンポーネントで入力値が変わったときに親へ通知したいだけなら、それは「レンダー後に同期する処理」ではなく、「入力イベントに対する処理」です。
この場合、Effect に置くよりも、イベントハンドラの中で親へ通知した方が自然です。
Before: Effect で親に通知する
function ItemCard({ item, onItemChange }: Props) {
const [inputPips, setInputPips] = useState('0')
const valueA = calculateValueA(inputPips)
const valueB = calculateValueB(inputPips)
const valueC = Number(inputPips) || 0
useEffect(() => {
onItemChange(item.id, valueA, valueB, valueC)
}, [item.id, valueA, valueB, valueC, onItemChange])
return (
<input
value={inputPips}
onChange={(e) => setInputPips(e.target.value)}
/>
)
}
この書き方だと、入力値の変更だけでなく、onItemChange の参照変化も Effect の再実行理由になります。
また、Effect はレンダー後に実行されるため、Effect 内で親 state を更新すると、余分な再レンダーが発生しやすくなります。
After: イベントハンドラで親に通知する
function ItemCard({ item, onItemChange }: Props) {
const [inputPips, setInputPips] = useState('0')
const handleInputChange = (nextInputPips: string) => {
setInputPips(nextInputPips)
const valueA = calculateValueA(nextInputPips)
const valueB = calculateValueB(nextInputPips)
const valueC = Number(nextInputPips) || 0
onItemChange(item.id, valueA, valueB, valueC)
}
return (
<input
value={inputPips}
onChange={(e) => handleInputChange(e.target.value)}
/>
)
}
この形なら、「入力が変わったから親に通知する」という因果関係が明確になります。
Effect を経由しないため、callback の参照変化による Effect 再実行も発生しません。
さらによい場合:state を親にリフトアップする
子の入力値と親が持つ集約 state が常に同期している必要があるなら、子に local state を持たせず、親に state を寄せる方法もあります。
function ListView({ items }: { items: Item[] }) {
const [cardState, setCardState] = useState<Record<string, CardState>>({})
const handleItemChange = useCallback((itemId: string, nextInputPips: string) => {
setCardState((prev) => ({
...prev,
[itemId]: {
inputPips: nextInputPips,
valueA: calculateValueA(nextInputPips),
valueB: calculateValueB(nextInputPips),
valueC: Number(nextInputPips) || 0,
},
}))
}, [])
return (
<>
{items.map((item) => (
<ItemCard
key={item.id}
item={item}
inputPips={cardState[item.id]?.inputPips ?? '0'}
onInputPipsChange={handleItemChange}
/>
))}
</>
)
}
function ItemCard({ item, inputPips, onInputPipsChange }: ItemCardProps) {
return (
<input
value={inputPips}
onChange={(e) => onInputPipsChange(item.id, e.target.value)}
/>
)
}
この設計では、親が正本の state を持ち、子は表示と入力イベントの通知に集中します。
複数コンポーネント間で状態を合わせたいときは、共通の親へ state を上げるのが React の基本的な考え方です。
では useCallback は不要なのか?
不要ではありません。
ただし、useCallback は「すべての関数に反射的につけるもの」ではなく、目的があるときに使うものです。
代表的には次のような場面です。
| 場面 |
useCallback の価値 |
|---|---|
React.memo した子に callback を渡す |
props の参照を安定させ、memo が効きやすくなる |
| callback が Effect の依存配列に入る | 不必要な Effect 再実行を減らせる |
| custom hook に callback を渡し、その hook が依存配列で使う | hook 内の再購読や再計算を抑えやすい |
例えば、親側で関数型更新を使えば、cardState を依存配列に入れずに済みます。
const handleItemChange = useCallback(
(itemId: string, valueA: number, valueB: number, valueC: number) => {
setCardState((prev) => ({
...prev,
[itemId]: { valueA, valueB, valueC },
}))
},
[]
)
逆に、次のように state を直接参照すると、その state を依存配列に入れる必要があり、state 更新のたびに callback 参照も変わります。
// 注意: cardState が変わるたびに handler の参照も変わる
const handleItemChange = useCallback(
(itemId: string, valueA: number, valueB: number, valueC: number) => {
setCardState({
...cardState,
[itemId]: { valueA, valueB, valueC },
})
},
[cardState]
)
useCallback を使うなら、関数型更新で依存を減らせないかを先に検討するとよいです。
ref パターンはいつ使うのか
useRef に最新 callback を保持して、Effect の依存配列から callback を外すパターンがあります。
function ItemCard({ item, onItemChange }: Props) {
const onItemChangeRef = useRef(onItemChange)
useEffect(() => {
onItemChangeRef.current = onItemChange
}, [onItemChange])
useEffect(() => {
const valueA = calculateValueA(item)
const valueB = calculateValueB(item)
const valueC = calculateValueC(item)
onItemChangeRef.current(item.id, valueA, valueB, valueC)
}, [item])
}
このパターンは、Effect 内で「最新の callback」は呼びたいが、「callback の参照変化」を Effect の再実行条件にはしたくない場合に使われます。
ただし、これはデフォルトで使うべきものではありません。
まず確認すべき順番は次です。
ref パターンは、Effect が本当に必要なケースでの escape hatch として考えるのが安全です。
React 19.2 以降なら useEffectEvent も候補
React 19.2 以降では、useEffectEvent が利用できます。
これは、Effect 内で呼びたい処理のうち、「最新の props / state は読みたいが、Effect の依存関係にはしたくない」ロジックを分離するための Hook です。
例として、WebSocket の購読は roomId が変わったときだけ張り直したいが、通知表示には最新の theme を使いたい、というケースがあります。
function ChatRoom({ roomId, theme }: Props) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme)
})
useEffect(() => {
const connection = createConnection(roomId)
connection.on('connected', () => {
onConnected()
})
connection.connect()
return () => {
connection.disconnect()
}
}, [roomId])
}
theme を Effect の依存配列に入れると、テーマ変更のたびに接続を張り直してしまいます。
一方で、useEffectEvent に切り出せば、接続の張り直し条件は roomId のまま、通知処理では最新の theme を読めます。
ただし、useEffectEvent も依存配列をごまかすための道具ではありません。
Effect から発火するイベントとして本当に切り出せるロジックにだけ使うべきです。
WebSocket のような外部更新では Effect が必要な場合もある
ここまで「Effect を避ける」話をしましたが、WebSocket、timer、DOM イベント、外部ライブラリなどと同期する場合は Effect が必要です。
例えば WebSocket の購読は、React の外側にあるシステムとの同期です。
function useMarketValue(symbol: string) {
const [marketValue, setMarketValue] = useState<number | null>(null)
useEffect(() => {
const socket = connectMarketSocket(symbol)
socket.on('price', (nextValue) => {
setMarketValue(nextValue)
})
return () => {
socket.close()
}
}, [symbol])
return marketValue
}
これは Effect に書くのが自然です。
一方で、その WebSocket 値を受けて「表示用の値を計算する」だけなら、Effect による state 更新ではなく、レンダー中の計算や useMemo で済むことがあります。
function ItemCard({ marketValue, inputPips }: Props) {
const displayValue = useMemo(() => {
return calculateDisplayValue(marketValue, inputPips)
}, [marketValue, inputPips])
return <div>{displayValue}</div>
}
React.memo は最後の最適化
React.memo は、props が変わっていないときに子コンポーネントの再レンダーをスキップするための最適化です。
const ItemCard = memo(function ItemCard({ item, inputPips, onInputPipsChange }: Props) {
return (
<input
value={inputPips}
onChange={(e) => onInputPipsChange(item.id, e.target.value)}
/>
)
})
ただし、React.memo は万能ではありません。
| 状況 |
React.memo の効果 |
|---|---|
| props がすべて同じ参照のまま | 再レンダーをスキップしやすい |
| callback が毎回新しい参照 | memo が効きにくい |
marketValue のような値が全カードに渡され、毎回変わる |
全カードの props が変わるため memo では止めにくい |
| 子の描画が軽い | memo 自体の比較コストの方が目立つ場合もある |
そのため、React.memo を使う前に、まず props の設計を見直すのが重要です。
例えば、全カードに毎回変わる marketValue を渡しているなら、当然すべてのカードの props が変わります。
この場合は、memo より先に「本当に全カードがその値を必要としているか」「購読範囲を分けられないか」「計算を局所化できないか」を検討します。
判断フロー
全体の判断は次のように整理できます。
まとめ
この記事の主張は、以前より少し変わりました。
以前は、次のような説明に寄っていました。
Effect 内で callback を呼ぶなら、親の
useCallbackと子の ref パターンで二重防御する。
これは、Effect を使う必要がある場面では今でも有効です。
ただし、より大事なのは次の順番です。
- その処理が本当に Effect なのかを確認する
- ユーザー操作が原因ならイベントハンドラに寄せる
- 親子で state を同期したいなら state のリフトアップを検討する
- 外部システムとの同期なら Effect を使う
- Effect 内で最新 callback を扱う必要があるなら、React 19.2 以降では
useEffectEvent、それ以前では ref パターンを検討する -
useCallbackとReact.memoは、props の参照安定化と再レンダー抑制が必要な場面で使う
useEffect の再実行を止めるテクニックは便利です。
しかし、それ以上に重要なのは、Effect を使わなくてよい処理を Effect に置かないことです。
不要な Effect を減らすことは、意図しない再実行や state 更新の連鎖を減らす一番シンプルな対策です。
追記:いただいた指摘について
本記事の初稿では、子コンポーネントの useEffect 内から親の onItemChange を呼び出す例を中心に、useCallback と ref パターンによる防御策を説明していました。
しかし、コメントでご指摘いただいた通り、コード例の範囲では、そもそも「レンダー起因である Effect」として処理する必要はなく、「イベント起因の更新」としてイベントハンドラ内で完結できる可能性が高いです。
そのため、記事全体を次の方針に修正しました。
- まず Effect が必要な処理かを判断する
- 入力変更やクリックなど、原因が明確なものはイベントハンドラに寄せる
- 子から親への通知も、可能なら Effect ではなくイベントハンドラで行う
- どうしても Effect が必要な場面に限って、
useCallback、ref パターン、useEffectEventを検討する
元の内容は「Effect を使う必要がある場面で callback 参照による再実行をどう抑えるか」という限定条件では有用ですが、記事内の例は Effect を使わずに整理できる可能性があるため、その前提を明確にしました。
ご指摘ありがとうございました。
参考
-
React: You Might Not Need an Effect
https://react.dev/learn/you-might-not-need-an-effect -
React: Synchronizing with Effects
https