Menu

ReactでuseEffectを使ったデータ取得:読み込み、エラー、競合

Reactでデータを取得するには、useEffectでリクエストを始め、読み込み中、エラー、データをstateに持ち、入力が変わった後に届いたレスポンスは無視します。エフェクト内のasync/await、AbortController、競合状態、そしてライブラリを使うべき場面を扱います。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

Reactでデータを取得するには、useEffect の中でリクエストを始め、レスポンスをstateに保存し、届くまでは読み込み中のメッセージを描画します。idのように、リクエストが依存する値を依存配列に入れておけば、それが変わったときにエフェクトがもう一度取得します。

このページの例はネットワークにアクセスできないので、fetchUser は偽のAPIです。fetch と同じように、少し待ってから解決するPromiseです。800 を 3000 に変えると、読み込み中のテキストが長く表示されます。

本物のAPIなら、エフェクトの本体は次のようになります。

useEffect(() => {
    fetch('https://api.example.com/users/1')
        .then((res) => res.json())
        .then((data) => setUser(data));
}, []);

読み込み中、エラー、データ

リクエストには、画面に表示すべき3つの結果があります。まだ読み込み中、失敗した、完了した、です。それぞれをstateに持ち、エフェクトが新しいリクエストを始めるときにリセットします。

User 3をクリックしてください。偽のAPIがrejectし、名前の代わりにエラーメッセージが表示され、コンソールにはエラーが出ます。finally ブロックは両方の経路で loading をクリアするので、失敗したリクエストがスピナーを出しっぱなしにすることはありません。

fetch が本物のサーバーと通信する場合、404や500のレスポンスではPromiseはrejectされません。res.ok を確認して、自分でthrowしてください。

const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

エフェクトの中のasync/await

useEffect に渡す関数を async にはできません。Reactはそれが何も返さないか、クリーンアップ関数を返すことを期待していますが、async関数は常にPromiseを返します。

// Wrong: the effect returns a promise
useEffect(async () => {
    const data = await fetchUser(id);
    setUser(data);
}, [id]);

// Right: define an async function inside and call it
useEffect(() => {
    async function load() {
        const data = await fetchUser(id);
        setUser(data);
    }
    load();
}, [id]);

競合状態

idがすばやく変わると、2つのリクエストが同時に進行します。それらが順番どおりに応答する保証はありません。古いほうが遅ければ最後に届き、ユーザーが最後に選んだidのデータを上書きしてしまいます。

解決策は ignore フラグです。エフェクトの実行ごとに自分のフラグがあり、そのクリーンアップがフラグを true にします。Reactは次の実行の前にクリーンアップを実行するので、古くなった実行からのレスポンスは ignore === true を見て捨てられます。次のブロックは同じプロフィールを、フラグなしとフラグありで2回描画します。

ボタンをクリックして待ってください。ユーザー2の速い応答が先に届くので、両方のプロフィールがログを出してGraceを表示します。クリックから約1.5秒後、ユーザー1の遅い応答が届きます。フラグのないプロフィールは、ユーザー2が選ばれているのにAdaに切り替わり、フラグのあるプロフィールは dropped stale Ada と記録してGraceのままです。

同じバグは、検索欄、タブ、サーバーでフィルターするあらゆるリストで起きます。上の読み込みの例は短くするためにフラグを省いていますが、エフェクトでのすべての取得にはこのフラグか、次に紹介する中断が必要です。

AbortControllerでキャンセルする

ignore フラグは古いレスポンスを捨てますが、リクエスト自体は最後まで実行されます。本物の fetch ならキャンセルできます。エフェクトで AbortController を作り、その signal を fetch に渡し、クリーンアップで abort() を呼びます。

useEffect(() => {
    const controller = new AbortController();

    async function load() {
        try {
            const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            setUser(await res.json());
        } catch (err) {
            if (err.name === 'AbortError') return; // cancelled on purpose
            setError(err);
        }
    }

    load();
    return () => controller.abort();
}, [id]);

中断された fetch は AbortError でrejectされるので、catchブロックはそれを失敗として表示せずに飛ばします。中断はアンマウントにも対応します。コンポーネントが取り除かれると、そのクリーンアップがリクエストをキャンセルします。

カスタムフックにまとめる

読み込み中、エラー、競合対策は、データを取得するどのコンポーネントでも同じです。それらをカスタムフックにまとめれば、各コンポーネントは1行でデータを求められます。

fetchPosts はコンポーネントの外で定義されているので同一性が変わらず、依存配列に入れても安全です。App の中で定義すると、レンダリングのたびに新しい関数になり、エフェクトがレンダリングのたびに実行され、エフェクトがstateをセットするので、取得が永遠に止まらなくなります。

500 を 2000 に変えて、ボタンを2回続けてクリックしてください。コンソールには2つのリクエストが表示され、最後に選んだトピックの投稿だけが現れます。

イベントハンドラでの取得

エフェクトは、コンポーネントが画面にあるから必要になるデータのためのものです。プロフィールページはそのプロフィールを読み込みます。SearchやSaveを押すなど、ユーザーが何かをしたからリクエストが起きる場合は、イベントハンドラで行ってください。何がきっかけかが正確にわかり、関係のないstateが変わっても何も再実行されません。

async function handleSubmit(e) {
    e.preventDefault();
    setStatus('saving');
    const res = await fetch('/api/notes', { method: 'POST', body: JSON.stringify({ text }) });
    setStatus(res.ok ? 'saved' : 'error');
}

よくある間違い

依存値の書き忘れ。id を読むのに [] を持つエフェクトは、最初のユーザーをずっと取得し続けます。リクエストが使うすべての値を並べてください。

コンポーネント本体での取得。エフェクトの外の fetch はレンダリングのたびに実行され、stateをセットするならループを始めます。

エラー時に fetch がrejectすると思い込む。rejectされるのはネットワークが失敗したときだけです。res.ok を確認してください。

読み込み中のリセットを忘れる。idが変わったら loading を true に戻してください。そうしないと、新しいデータが届く途中だというサインもなく、古いデータが画面に残ります。

ライブラリやフレームワークを使うべきとき

いくつかのリクエストなら、エフェクトでの取得で十分です。ただしキャッシュはしません。同じプロフィールを2回開けば2回取得します。コンポーネント間でデータを共有したり、失敗時にリトライしたり、タブが再びフォーカスされたときに再取得したりもしません。ライブラリがこれらをやってくれます。

import { useQuery } from '@tanstack/react-query';

function Profile({ id }) {
    const { data, error, isPending } = useQuery({
        queryKey: ['user', id],
        queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
    });

    if (isPending) return <p>Loading...</p>;
    if (error) return <p>{error.message}</p>;
    return <p>{data.name}</p>;
}

クライアント側のデータには、TanStack QueryとSWRがよく選ばれます。フレームワークはさらに進んで、ページがブラウザに届く前にサーバーでデータを読み込みます。Next.jsならサーバーコンポーネントで、React Routerならローダーでです。これにより、読み込み中の表示のちらつきや、親のデータが届くまで子が取得を始められないことによるリクエストの連鎖を避けられます。

React 19:Suspenseとuse()

React 19では、レンダリング中にPromiseを読む use が追加されました。コンポーネントはPromiseが解決するまでサスペンドし、その間は最も近い <Suspense> 境界がフォールバックを表示するので、コンポーネント自体は読み込み中のstateを持ちません。

import { use, Suspense } from 'react';

function Profile({ userPromise }) {
    const user = use(userPromise);
    return <p>{user.name}</p>;
}

<Suspense fallback={<p>Loading...</p>}>
    <Profile userPromise={userPromise} />
</Suspense>

Promiseは、それを読むレンダリングの中ではなく、コンポーネントの外(フレームワーク、キャッシュ、親)で作る必要があります。そうしないと、レンダリングのたびに新しいリクエストが始まります。useフックのページで、動く例とともに扱っています。

よくある質問

Reactのコンポーネントが読み込まれたときにデータを取得するには?

useEffect でリクエストを始め、それが依存する値を依存配列に入れ(なければ [])、結果を useState でstateに保存します。データが届くまでは読み込み中のメッセージを描画します。

useEffectのコールバックをasyncにできないのはなぜですか?

async関数は常にPromiseを返しますが、Reactはエフェクトが何も返さないか、クリーンアップ関数を返すことを期待しているからです。エフェクトの中でasync関数を書き、すぐに呼んでください。

Reactのデータ取得における競合状態とは何ですか?

入力がすばやく変わると2つのリクエストが同時に進行し、古いほうが最後に応答して新しいデータを上書きすることがあります。エフェクトのクリーンアップで ignore フラグを立て、立っていたら setState を飛ばすか、AbortController でリクエストをキャンセルしてください。

データ取得にはuseEffectとライブラリのどちらを使うべきですか?

useEffectは小さなアプリなら十分で、理解しておく価値があります。キャッシュ、重複の排除、リトライ、再取得が必要なら、TanStack QueryやSWRのようなライブラリや、Next.jsのようなフレームワークに組み込まれたデータ読み込みを使えば、そのロジックを自分で書かずに済みます。

データの読み込み中にスピナーを表示するには?

stateに loading の真偽値(または status 文字列)を持ち、リクエストを始める前にセットし、成功とエラーの両方の経路でリクエストが終わったときにクリアします。trueの間はスピナーを描画します。

Coddyのプログラミング言語のイラスト

Coddyでコードを学ぼう

始める