ReactのuseEffect
フック内で非同期操作を使用することは一般的な要件ですが、初心者にとっては混乱やバグの原因となることもあります。ここでは注意すべき10個のよくある間違いを紹介します。
1. useEffect
を直接async
関数にする
useEffect
フックは、そのコールバック関数が何も返さない(undefined)か、クリーンアップ関数を返すことを期待しています。async
関数は定義上、Promise を返します。
間違い:
useEffect(async () => {
const data = await fetchData();
// ...
}, []);
なぜこれが問題なのか: Reactは返された Promise をクリーンアップ関数として解釈し、エラーや予期しない動作を引き起こす可能性が高くなります。Promise は呼び出し可能な関数ではないため、Reactがクリーンアップを実行しようとすると(例えば、コンポーネントのアンマウント時やエフェクトの再実行前)、ランタイムエラーが発生する可能性があります。
解決策:useEffect
の内部でasync
関数を定義し、それを呼び出します。
useEffect(() => {
const fetchDataAsync = async () => {
const data = await fetchData();
// ...
};
fetchDataAsync();
}, []);
2. 依存配列を忘れる
依存配列はuseEffect
にいつ再実行すべきかを伝えます。props や state に基づいてデータをフェッチしている場合、それらの依存関係を含める必要があります。
間違い:
useEffect(() => {
const fetchDataAsync = async () => {
const data = await fetchData(props.id);
// ...
};
fetchDataAsync();
}, []); // props.id がありません
なぜこれが問題なのか:
- 依存配列を完全に省略すると、エフェクトはコンポーネントのすべてのレンダリング後に実行されます。エフェクト自体が状態更新をトリガーする場合、無限ループに陥る可能性があります。
- 空の配列
[]
を指定すると、エフェクトは最初のレンダリング後に一度だけ実行されます。props.id
のような外部の依存関係が変更されても、エフェクトは再実行されず、古いデータで動作し続けるため、UI が実際のアプリケーションの状態と同期しなくなります。
解決策: エフェクトが依存する外部スコープのすべての変数を含めます。
useEffect(() => {
const fetchDataAsync = async () => {
const data = await fetchData(props.id);
// ...
};
fetchDataAsync();
}, [props.id]);
3. アンマウントされたコンポーネントを処理しない
非同期操作が進行中にコンポーネントがアンマウントされると、アンマウントされたコンポーネントの state を更新しようとするとメモリリークの警告が発生します。
間違い:
useEffect(() => {
const fetchDataAsync = async () => {
const result = await fetchData();
setData(result); // コンポーネントがアンマウントされるとエラーの可能性
};
fetchDataAsync();
// クリーンアップなし
}, []);
なぜこれが問題なのか: 非同期操作が完了する前にコンポーネントがアンマウントされると、その後の state 更新の試み(例:setData(result)
)は、もはや DOM に存在しないコンポーネントインスタンスを対象とします。Reactはこれを検出し、「アンマウントされたコンポーネントでReactの state 更新を実行できません」という警告を発します。これはメモリリークを示し、関連性のない作業を実行しようとしていることを意味します。
解決策: クリーンアップ関数を使用してマウント状態を追跡します。
useEffect(() => {
let isMounted = true;
const fetchDataAsync = async () => {
const result = await fetchData();
if (isMounted) {
setData(result);
}
};
fetchDataAsync();
return () => {
isMounted = false;
};
}, []);
または、フェッチライブラリがサポートしている場合はAbortController
を使用します。
4. 非同期操作のエラー処理を無視する
ネットワークリクエストやその他の非同期操作は失敗する可能性があります。これらのエラーを処理しないと、アプリケーションがクラッシュしたり、矛盾した状態になったりする可能性があります。
間違い:
useEffect(() => {
const fetchDataAsync = async () => {
const data = await fetchData(); // これがエラーをスローしたらどうなるか?
setData(data);
};
fetchDataAsync();
}, []);
なぜこれが問題なのか: ネットワーク API のような外部リソースを含む非同期操作は本質的に失敗しやすいです。エラーがtry...catch
ブロックで明示的にキャッチされない場合、Promise は拒否され、「未処理の Promise 拒否」となります。これにより、ブラウザコンソールに不可解なエラーメッセージが表示されたり、アプリケーションが不安定な状態になったりする可能性があります。
解決策:try...catch
ブロックを使用します。
useEffect(() => {
const fetchDataAsync = async () => {
try {
setLoading(true);
const data = await fetchData();
setData(data);
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
};
fetchDataAsync();
}, []);
5. 急速に変化する依存関係による競合状態
依存関係が急速に変化すると、複数の非同期操作が開始され、順不同で解決される可能性があり、UI が矛盾する可能性があります。
間違い:
useEffect(() => {
const fetchDataAsync = async () => {
const data = await fetchData(searchTerm);
setResults(data);
};
fetchDataAsync();
}, [searchTerm]); // searchTerm は急速に変化します
なぜこれが問題なのか: ライブ検索入力のようなシナリオを考えてみましょう。ユーザーが「react」と素早く入力すると、複数のリクエストがディスパッチされる可能性があります。ネットワークの遅延やサーバーの応答時間は予測できないため、これらの非同期操作は開始された順序で完了しない場合があります。例えば、「reac」のリクエストが「react」のリクエストが完了して状態を更新した後に返されるかもしれません。これにより、UI が「react」の結果を表示した後に突然「reac」の結果にちらついたり戻ったりする可能性があり、ユーザーエクスペリエンスが損なわれます。
解決策: フラグやAbortController
を使用して、古い応答を無視するクリーンアップメカニズムを実装します。
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const fetchDataAsync = async () => {
try {
const data = await fetchData(searchTerm, { signal });
setResults(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('フェッチが中止されました');
} else {
// 他のエラーを処理
}
}
};
if (searchTerm) {
fetchDataAsync();
}
return () => {
controller.abort();
};
}, [searchTerm]);
6. データの過剰なフェッチ
依存関係が不安定なため(例: 依存配列にインラインで定義されたオブジェクトや配列)、useEffect
が不必要に再実行されると、過剰な API 呼び出しが発生する可能性があります。
間違い:
useEffect(() => {
// ...データをフェッチ
}, [options]); //`options`はレンダー関数で作成されたオブジェクトです
なぜこれが問題なのか: ReactのuseEffect
フックは、依存配列内のアイテムをレンダー間で浅い比較(通常はObject.is
ロジックを使用)することによって、エフェクトを再実行するかどうかを決定します。依存関係がレンダーごとに再作成されるオブジェクトや配列である場合(例: コンポーネント関数本体内で直接定義されたconst options = { param: 'value' };
)、それはレンダーごとに新しいオブジェクト/配列参照となります。内容や構造が前のレンダーと同一であっても、参照が変更されているため、浅い比較では異なる値として認識されます。これにより、useEffect
はレンダーごとにコールバック関数を再実行し、非同期ロジックの不必要な実行を引き起こします。
解決策:useMemo
やuseCallback
を使用して非プリミティブな依存関係をメモ化するか、適切であれば文字列化します。
const options = useMemo(() => ({ param: 'value' }), []);
useEffect(() => {
// ...データをフェッチ
}, [options]);
7. ローディング状態を使用しない
データがフェッチされている間、ユーザーにはフィードバックが必要です。ローディング状態がないと、UI が応答していないように見えたり、古いデータが表示されたりする可能性があります。
間違い:
useEffect(() => {
const fetchDataAsync = async () => {
const data = await fetchData();
setData(data);
};
fetchDataAsync();
}, []);
// setLoading(true) や setLoading(false) がありません
なぜこれが問題なのか: サーバーからデータをフェッチするなどの非同期操作が開始されると、操作が完了してデータが利用可能になるまでに固有の遅延が発生します。この待機期間中にユーザーインターフェース(UI)が視覚的なフィードバックを提供しない場合、アプリケーションはフリーズしたか、応答不能か、壊れているように見える可能性があります。これにより、ユーザーの不満、繰り返しのクリックやアクション(複数の冗長なリクエストをトリガーする可能性あり)、さらにはユーザーがアプリケーションを放棄することにつながる可能性があります。
解決策: ローディング状態を実装します。
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchDataAsync = async () => {
setLoading(true);
try {
const data = await fetchData();
setData(data);
} catch (e) { /* ... */ }
setLoading(false);
};
fetchDataAsync();
}, []);
if (loading) return <p>ローディング中...</p>;
8. 依存関係からのasync
関数を誤って使用する
async
関数が prop として渡され、useEffect
で使用される場合、それが安定しているか、useCallback
でメモ化されていることを確認してください。
間違い:
// 親コンポーネント
function ParentComponent() {
const handleFetch = async () => { /* ... */ };
return <ChildComponent fetchData={handleFetch} />;
}
// 子コンポーネント
function ChildComponent(props) {
useEffect(() => {
props.fetchData();
}, [props.fetchData]); // fetchData は親のレンダーごとに新しい関数です
return <div>Child Content</div>;
}
なぜこれが問題なのか: JavaScript では、関数はオブジェクトです。関数がReactコンポーネントの本体内で定義されると(親の例のhandleFetch
のように)、親コンポーネントが再レンダリングされるたびにその関数の新しいインスタンスが作成されます(useCallback
などでメモ化されていない限り)。このhandleFetch
関数が prop (fetchData
) として子コンポーネントに渡され、子コンポーネントがそのuseEffect
の依存配列でprops.fetchData
を使用する場合、親が再レンダリングされるたびに、子はprops.fetchData
の新しい関数参照を受け取ります。これにより、子のuseEffect
は親の再レンダリングのたびに不必要に再実行されます。
解決策: 親コンポーネントでhandleFetch
をuseCallback
でラップします。
// 親コンポーネント
function ParentComponent() {
const handleFetch = useCallback(async () => { /* ... */ }, []);
return <ChildComponent fetchData={handleFetch} />;
}
// 子コンポーネント
function ChildComponent(props) {
useEffect(() => {
props.fetchData();
}, [props.fetchData]); // fetchData は親のレンダーごとに新しい関数です
return <div>Child Content</div>;
}
9. useEffect
のクリーンアップが常に非同期完了後に実行されると仮定する
useEffect
のクリーンアップ関数は、コンポーネントがアンマウントされるとき、または依存関係の変更によりエフェクトが再実行される前に実行されます。エフェクト内の非同期操作が完了するのを待つことはありません。
間違い: クリーンアップはawait
ステートメントが解決された後にのみ発生すると信じている。
なぜこれが問題なのか:useEffect
から返されるクリーンアップ関数が、メインエフェクトコールバック内のawait
呼び出しやその他の非同期タスクが終了するのを何らかの形で待ってから実行されるという誤解がよくあります。これは誤りです。クリーンアップ関数の実行は、コンポーネントのライフサイクル(アンマウント)または依存関係の変更(エフェクトの再実行をトリガー)に結びついています。非同期操作がまだ保留中の場合(例えば、await fetchData()
呼び出しがまだ解決していない場合)、クリーンアップ関数は即座に実行されます。
解決策:useEffect
のライフサイクルを理解します。コンポーネントがアンマウントされたり、依存関係が途中で変更されたりした場合のキャンセルを処理するために、非同期操作自体の中でフラグまたはAbortController
を使用します。
10. 複雑なロジックをuseEffect
内に直接記述する
単一のuseEffect
にあまりにも多くの複雑な非同期ロジック、状態更新、条件付きフェッチを詰め込むと、読み取り、テスト、デバッグが困難になる可能性があります。
間違い:
useEffect(() => {
const complexAsyncLogic = async () => {
if (conditionA) {
const dataA = await fetchA();
if (dataA) {
const dataB = await fetchB(dataA.id);
// ...さらなるロジックと状態更新
}
} else {
// ...他の非同期パス
}
};
complexAsyncLogic();
return () => { /* 複雑なクリーンアップ */ };
}, [depA, depB, depC]);
なぜこれが問題なのか: 単一のuseEffect
フックが多数の非同期操作、複数の状態変数に基づく複雑な条件付きフェッチロジック、複雑なデータ変換、および多数の状態更新の集積場所になると、そのエフェクト内のコードは急速に複雑になり、追跡が困難になります。これにより、可読性が大幅に低下し、エフェクトの動作を推論することが困難になります。依存配列が非常に大きくなり、正しく管理することが難しくなり、古いクロージャ、依存関係の欠落(古いデータにつながる)、または不必要な再実行(パフォーマンスの問題につながる)などの微妙なバグのリスクが高まります。
解決策:
- 複雑な非同期ロジックを、より小さく再利用可能な(カスタム)フックまたはヘルパー関数に分割します。
- 複雑な非同期フローを管理するために、ステートマシンまたはリデューサーを検討します。
- 関心事を分離します。可能であれば、1つの主要な非同期タスクに対して1つのエフェクトを使用します。
まとめ
この記事では、reactのuseEffectで非同期処理を使う際に初心者がよくやってしまう10の間違いを解説しました。特に、async関数を直接useEffectに書かない、依存配列を正しく設定する、アンマウント後の状態更新を防ぐなど、基本的なルールを守ることで、安全でバグの少ないコードを書くことができます。ぜひこれらのポイントを意識して、より安定したreactアプリを作りましょう。