【実務・中級編】 非同期イベントハンドラ内での状態更新 – React実践ガイド

お疲れ。少し手を止めて、コーヒーでも飲みながら聞いてくれ。

君もそろそろ「Reactの基本は一通りマスターした、実務の機能開発も任せてくれ」というフェーズだと思う。だがな、中級の壁として必ず立ちはだかるのが「非同期処理と状態管理の絶妙なズレ」なんだよ。

コードレビューをしていて、一番頭を抱えたくなるのがここだ。`async/await` を使ったボタンのクリックハンドラ。APIからデータを取ってきて、ローディングを戻して……一見、綺麗に書けているように見えるそのコード、本当に安全か?
ユーザーがせっかちに画面をパッパと切り替えたとき、ブラウザのコンソールに「Can’t perform a React state update on an unmounted component…」なんていう、あの悪名高い赤字エラーが出ていないか?

今日は、現場で生きるプロとして、この「非同期イベントハンドラ内での状態更新の罠」と、その泥臭くて確実な対策を叩き込んでやる。心して聞いてくれ。

—

1. なぜ非同期処理の中で状態(State)更新がバグるのか?

まずは敵を知ることから始めよう。Reactの `useState` は、ただの変数じゃない。Reactのレンダリングライフサイクルと密に結合された「トリガー」だ。

よくあるのが、こんなコードだ。

// ⚠️ ありがちな危ういコード
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);

const handleClick = async () => {
setLoading(true);

// 重いAPIリクエストを待つ(非同期)
const result = await fetchSomething();

// この瞬間、コンポーネントがすでに画面から消えているかもしれない!
setData(result);
setLoading(false);
};

何が問題か分かるか?
`await` で待っている間に、ユーザーが別のページに遷移したり、親コンポーネントの条件分岐によってこのコンポーネントがアンマウント(DOMから削除)されたとする。

JavaScriptの実行コンテキストはそのまま非同期処理の続き(`await` の下)を実行し、存在しないコンポーネントの `setData` や `setLoading` を呼び出そうとする。Reactはここで「おい、もういない奴のステートを更新しようとしてるぞ!」と警告を鳴らすわけだ。

React 18以降、このエラーは自動的なメモリリーク検出の観点から挙動が変わった部分もあるが、「アンマウントされたコンポーネントのステートを書き換えることによる無駄な処理」や「予期せぬバグ」が消えたわけではない。 ここを舐めてると、実務のプロダクション環境で痛い目をみる。

—

2. 実務で使えるベストプラクティス:安全な状態更新の二大アプローチ

じゃあ、どうするか? 現場で私たちが使っている実践的な防衛策は主に2つある。

1. `useRef` を使ったマウント状態の追跡(フラグ管理)
2. AbortController による通信自体のキャンセル(根本治療)

この2つを組み合わせるのが、プロのフロントエンドエンジニアの仕事というものだ。それぞれコードを見ていこう。

アプローチA: `useRef` でマウント状態を監視する(一番手軽で確実な保険)

コンポーネントが生きているかどうかを `useRef` で追跡する。`useRef` は値を書き換えても再レンダリングを引き起こさないため、こういう「裏でのフラグ管理」にはうってつけの相棒だ。

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

export const SafeAsyncButton = () => {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(”);

// 1. マウント状態を保持するRefを作成
const isMountedRef = useRef(true);

useEffect(() => {
// マウント時にtrueにする
isMountedRef.current = true;

// アンマウント時(クリーンアップ関数)にfalseにする
return () => {
isMountedRef.current = false;
};
}, []);

const handleAction = async () => {
setLoading(true);
setMessage(”);

try {
// 模擬的な重い非同期処理(2秒かかるAPI叩き)
await new Promise((resolve) => setTimeout(resolve, 2000));

// 2. 処理が再開した時点で、まだコンポーネントが生きてるかチェック!
if (!isMountedRef.current) {
console.log(‘すでにアンマウントされているため、状態更新をスキップします。’);
return;
}

setMessage(‘データの取得に成功しました!’);
} catch (error) {
if (!isMountedRef.current) return;
setMessage(‘エラーが発生しました。’);
} finally {
// 3. ここでも生存確認を忘れない
if (isMountedRef.current) {
setLoading(false);
}
}
};

return (

{message}

);
};

どうだ? これだけでもう、「消えた幽霊コンポーネントのステートを叩く」という恐怖から解放される。コピペしてすぐにでもチームのプロジェクトに導入できるはずだ。

—

アプローチB: `AbortController` で通信そのものをキャンセルする(さらにワンランク上のアプローチ)

ステートの更新を防ぐだけじゃなく、そもそも「不要になった通信をブラウザ側でバッサリ切り捨てる」のが、ネットワーク帯域やサーバー負荷を考慮したプロの技だ。

ここで登場するのが、Web標準APIの `AbortController` だ。

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

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

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

const fetchData = async () => {
setLoading(true);
try {
// fetchにsignalを渡すことで、キャンセルが可能になる
const response = await fetch(‘https://api.example.com/data’, { signal });
const json = await response.json();

setData(json);
} catch (error: any) {
// キャンセルされた場合のエラーは無視する
if (error.name === ‘AbortError’) {
console.log(‘通信は正常にキャンセルされました’);
return;
}
console.error(‘その他のエラー:’, error);
} finally {
setLoading(false);
}
};

fetchData();

// 2. コンポーネントが破棄される瞬間に通信をabort(中断)する
return () => {
controller.abort();
};
}, []);

return (

{loading ?

ローディング中…

:

{JSON.stringify(data, null, 2)}

}

);
};

このアプローチの何が素晴らしいって、ユーザーがページを離脱した瞬間に、裏で走っていた無駄な通信ネットワークがパチンと切断されるところだ。モバイル環境や、通信制限のあるユーザーに対して極めて優しい、シニアらしい気配りの効いたコードと言える。

—

3. チーフアーキテクトからの現場の心得

最後に、チームで開発を進める上での心構えを一つ。

非同期処理とReactの状態管理は、一見するとシンプルに見えて、エッジケース(例外的なユーザーの動き)を踏むと一瞬でボロが出る。だからこそ、コードレビューのときは以下の視点を持つように意識してほしい。

  • 「この `await` の後、このコンポーネントは確実に存在しているか?」
  • 「ユーザーが連打したとき、古いリクエストの結果が新しい結果を上書きする(競合状態:Race Condition)リスクはないか?」(※これについてはまた別の機会に話そう。カスタムフックで `useCallback` や `useRef` を駆使する領域だ)

基礎の積み重ねが、プロダクトの堅牢性を作る。
動くだけのコードは素人にでも書ける。だが、「保守性が高く、どんな荒い操作をされても壊れない美しいコード」を書くのが、我々プロのフロントエンドエンジニアの仕事だ。

よし、今日の知見はここまでだ。各自のプロジェクトコードに戻って、危なっかしい `async/await` がないか点検してみるといい。何か躓いたら、いつでも声をかけてくれ。

コメント

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