【テクニカル・上級編】 非同期イベントハンドラ内での状態更新 – React実践ガイド

非同期イベントハンドラと状態管理の深層:競合とメモリリークを制する者だけがReactを制す

フロントエンドの現場で日々コードを書き殴っていると、`useState`の挙動に足をすくわれる瞬間というのは突然やってくる。いや、正確に言えば、私たちがReactのレンダリングライフサイクルとJavaScriptの非同期ランタイム(Event Loop)の境界線を曖昧に捉えているからこそ、その「バグ」という名の怪物は牙を剥くのだ。

特に、`async/await`やPromiseが絡むイベントハンドラ内での状態更新。この領域は、表面的なAPIの使い方を知っているだけでは、いずれプロダクション環境で致命的な不整合を引き起こす。今回は、ブラウザエンジンやReactの内部アーキテクチャの視点まで踏み込みながら、非同期処理に伴う状態更新のバグと、マウント解除後のメモリリークを防ぐための極限のテクニックを解き明かしていこう。

—

1. なぜ「非同期処理後の状態更新」は牙を剥くのか?

JavaScriptはシングルスレッドで動作し、非同期処理の完了はマイクロタスクキュー(Microtask Queue)を介してイベントループに制御が戻ったタイミングで処理される。ここで重要なのは、「非同期処理が走っている最中も、Reactの世界は無慈悲に進み続ける」という事実だ。

以下のコードを見てほしい。一見、何の問題もないように見えるだろう。

import { useState } from ‘react’;

export const NaiveCounter = () => {
const [count, setCount] = useState(0);

const handleClick = async () => {
// 意図的に重いAPIリクエストや非同期処理を模倣
const response = await fetchUserData();

// 現在の count をベースに加算したいが…
setCount(count + 1);
};

return ;
};

このコードの何が問題か? `await` が解決して `setCount(count + 1)` が実行されるまでの間に、もし親コンポーネントの再レンダリングや別の状態更新によって `count` の値が変化していたら、このクロージャ内にある `count` は古い値(Stale State)を指し続けることになる。

さらに悪質なのは、ユーザーがボタンを短時間に2回連続でクリックした場合だ。最初のクリックによる非同期処理が完了する前に2回目のクリックが発生すると、両方のイベントハンドラが同じ古い `count` を参照して `setCount` を呼び出すため、更新が「競合」し、本来2回カウントアップされるべきところが1回分しか反映されないというサイレントバグが生まれる。

関数型アップデートによる競合の排除

この問題を解決する第一歩は、お馴染みだが「関数型アップデート(Functional Update)」の徹底だ。

// 修正版:最新の状態を確実にキャプチャする
const handleRobustClick = async () => {
const response = await fetchUserData();

// 引数 prev には、Reactがその瞬間に保持する最新の状態が渡される
setCount((prevCount) => prevCount + 1);
};

関数型アップデートを使うことで、クロージャのキャプチャ問題や競合状態(Race Condition)の大部分を回避できる。だが、非同期処理における真の悪夢は、状態の競合だけではない。「アンマウントされたコンポーネントへの状態更新」というメモリ効率とエラーハンドリングの壁が残っている。

—

2. アンマウント問題とメモリリークの深層

ユーザーが非同期処理の完了を待っている最中に、ページを遷移したり、タブを閉じたりしてコンポーネントがアンマウントされたとする。この時、JavaScriptのエンジンはどう動くだろうか?

非同期処理(Promise)自体はバックグラウンド(またはWeb API層)で継続し、それが解決した瞬間にコールバック(マイクロタスク)が実行される。その内部で `setCount` が呼ばれた場合、Reactは「すでに存在しないファイバーツリー(Fiber Tree)」に対して状態更新を試みることになる。

モダンなReact(React 18以降)では、かつてのような「Can’t perform a React state update on an unmounted component…」という赤字のコンソールエラーはデフォルトでは出にくくなった。しかし、だからといって問題が解決したわけではない。

1. メモリリークの温床: アンマウントされたコンポーネントのインスタンスや、そのクロージャが保持している巨大なオブジェクト群が、ガベージコレクション(GC)の回収対象から外れ、メモリ上に残り続ける。
2. 無駄なCPUサイクルの消費: 消え去った画面のために状態を再計算し、レンダリングプロセスを走らせようとする無駄な負荷。

では、プロフェッショナルはこの問題にどう立ち向かうのか。実務レベルで即座に採用できる2つのアプローチを提示しよう。

—

3. 実践:堅牢な非同期状態管理の実装パターン

アプローチ A: `useRef` によるマウント状態の追跡(王道かつ確実な手法)

Reactのライフサイクル外で「今、このコンポーネントが画面に生きているか」をフラグで管理する。`useState` ではなく `useRef` を使うのは、値の変更自体で再レンダリングをトリガーしたくないからだ。

import { useState, useEffect, useRef } from ‘react’;

export const SafeAsyncComponent = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);

// コンポーネントのマウント状態を保持するref
const isMounted = useRef(true);

useEffect(() => {
// マウント時にtrueを設定
isMounted.current = true;

// アンマウント時にfalseに書き換える
return () => {
isMounted.current = false;
};
}, []);

const handleFetch = async () => {
setLoading(true);
try {
const result = await fetchSomeDataFromAPI();

// 非同期処理完了時に、まだコンポーネントが生きていればのみ状態を更新
if (isMounted.current) {
setData(result);
}
} catch (error) {
if (isMounted.current) {
console.error(‘データ取得失敗:’, error);
}
} finally {
if (isMounted.current) {
setLoading(false);
}
}
};

return (

Result: {data ?? ‘未取得’}

);
};

このパターンは極めてシンプルでありながら、実務において絶大な信頼性を誇る。余計なライブラリに依存せず、JavaScriptのクロージャと参照の性質を的確に突いた美しい実装だ。

—

アプローチ B: `AbortController` によるネットワーク層からの根絶

メモリリーク対策として「状態を更新しない」のは有効だが、そもそも不要になったネットワークリクエスト自体をキャンセルできるなら、それに越したことはない。ブラウザの標準APIである `AbortController` をReactのライフサイクルと結合させよう。

import { useState, useEffect } from ‘react’;

export const OptimizedFetchComponent = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);

useEffect(() => {
// AbortControllerのインスタンスを生成
const controller = new AbortController();

const loadData = async () => {
setLoading(true);
try {
// fetchにsignalを渡してリクエストとコントローラーを紐付ける
const response = await fetch(‘/api/heavy-endpoint’, {
signal: controller.signal,
});
const json = await response.json();

setData(json.data);
} catch (error: any) {
// ユーザーによるキャンセルやアンマウントによる中断はエラーとして扱わない
if (error.name === ‘AbortError’) {
console.log(‘リクエストは正常にキャンセルされました’);
return;
}
console.error(‘予期せぬエラー:’, error);
} finally {
setLoading(false);
}
};

loadData();

// クリーンアップ関数でリクエストを即座にアボート(中止)する
return () => {
controller.abort();
};
}, []);

return

{loading ? ‘Loading…’ : data}

;
};

このアプローチの美しさは、ブラウザのネットワーク帯域やCPUリソースの無駄遣いを根本から断ち切る点にある。コンポーネントが消えた瞬間、通信そのものがスロットルされ、サーバー側の負荷軽減にも寄与する。これこそが、アーキテクト視点での真のパフォーマンス最適化だ。

—

まとめ:コードの裏側にある「因果関係」を愛せ

Reactの `useState` は非常にプリミティブで扱いやすい反面、非同期処理という現代Webアプリケーションの不可避な複雑性と組み合わさった瞬間、予測不能なバグの温床となる。

  • 非同期処理後の状態更新には、関数型アップデートを用いて古い状態の参照を防ぐ。
  • コンポーネントの生死を `useRef` で監視し、アンマウント後の無駄な状態更新とメモリリークを塞ぐ。
  • `AbortController` を駆使し、不要になった非同期タスクそのものをライフサイクルと連動させてkillする。

フレームワークがどれほど進化し、CompilerやConcurrent Modeが自動化の領域を広げようとも、私たちが書くコードの裏側で「イベントループとメモリがどう動いているのか」という物理的なイメージを持つことの価値は揺るがない。

泥臭い非同期の競合をコードの知性でねじ伏せたとき、あなたの書くアプリケーションは、真に堅牢で美しく、そして冷徹なまでに高速なシステムへと昇華されるはずだ。

コメント

タイトルとURLをコピーしました