この症状に当てはまりますか?

  • スピナーが回り続けてページが表示されない
  • 'Loading...' の文字がいつまでも消えない
  • 更新しても何も変わらない
  • DevTools(F12)の Console に赤いエラーが出ている、または Network タブにリクエスト失敗(赤)が表示される

無限スピナーは、ほとんどの場合まったく同じことを意味します。つまり、データの読み込みが最後まで終わらず、loading フラグが一度もオフにされていないのです。よくある原因は4つ — そのほとんどは2行のコードで直ります。

原因1 — リクエスト失敗時に loading がクリアされない(最も多い)

よくあるコード:

jsx

const [loading, setLoading] = useState(true);

useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      setData(data);
      setLoading(false); // only runs on success
    });
}, []);

リクエストが失敗する(ネットワーク切断、CORS ブロック)と、処理が .then まで到達しないため setLoading(false) が呼ばれず、スピナーが永遠に回り続けます。MDN によると、fetch はネットワークエラーや不正な URL のときに promise を reject します(CORS 失敗もネットワークエラーとして扱われます)。

対処法: finally で loading をクリアする

呼び出しを try / catch / finally で囲み、finally ブロックが成功でも失敗でも必ず実行されるようにします。MDN でも、finally(および Promise.prototype.finally)は promise が fulfilled でも rejected でも実行されると明記されており、loading フラグのクリアのような後処理を置く場所として最適です。

jsx

useEffect(() => {
  const load = async () => {
    try {
      setLoading(true);
      const res = await fetch('/api/data');
      if (!res.ok) throw new Error(`HTTP ${res.status}`); // see Cause 2
      setData(await res.json());
    } catch (e) {
      setError(e.message);   // show an error state
    } finally {
      setLoading(false);     // always runs → spinner always stops
    }
  };
  load();
}, []);

.then() チェーンを使っている場合は、末尾に .finally(() => setLoading(false)) を追加します。

> CORS エラー(Network タブに 'blocked by CORS policy' と表示される)の場合、finally でスピナーは止まりますが、データは取得できません。本当の対処はサーバー側です。API 側が Access-Control-Allow-Origin ヘッダーを返す必要があり、フロントエンドからは直せません。

原因2 — 404/500 が成功扱いになっている

fetch() は 404 や 500 のような HTTP エラーステータスでは例外を投げません — promise は正常に resolve します。そのため、エラーページ(HTML)を res.json() しようとしてクラッシュしたり、空のデータで描画してしまったりします。MDN いわく、fetch の promise は 404/504 では reject しないので、Response.ok / Response.status を自分でチェックする必要があります。

対処法: 必ず res.ok をチェックする

それが上のコードの if (!res.ok) throw ... の行です。これで 404 は catch に入り、無限スピナーではなくエラー状態が表示されます。

原因3 — useEffect が無限ループする

依存配列(dependency array)が間違っていると、コンポーネントが再レンダリング → 再フェッチ → 再レンダリング… を繰り返し、スピナーが止まりません。

よくある2つのミス:

jsx

useEffect(() => { /* ... */ });            // 🚩 no array → runs after every render
useEffect(() => { /* ... */ }, [options]); // 🚩 options is an object → new on every render

React 公式ドキュメント: オブジェクト・配列・関数は毎回のレンダリングで作り直されるため、依存配列に入れるとコミットのたびに値が変わり、無限ループになります。

対処法

  • 一度だけ実行する → 空の配列 [] を渡す: useEffect(() => { ... }, []);
  • オブジェクトや関数は Effect の内側で作り、依存配列にはプリミティブ(文字列・数値)だけを残す:
jsx

useEffect(() => {
  const options = { userId, roomId }; // created inside the Effect
  // ...
}, [userId, roomId]); // primitives only
  • 自分自身から state を更新するときは updater 形式を使う: setCount(c => c + 1)[count] を付けた setCount(count + 1) はループの危険があるので避ける)。

原因4 — レスポンスの順序が入れ替わる(タブ/検索の高速切り替え)

入力を素早く変えると、古いリクエストが最後に resolve して state が固まってしまうことがあります。React 公式のパターンでは、クリーンアップで ignore フラグを使います:

jsx

useEffect(() => {
  let ignore = false;
  const load = async () => {
    try {
      const res = await fetch(`/api/data?id=${id}`);
      const data = await res.json();
      if (!ignore) setData(data);
    } finally {
      if (!ignore) setLoading(false);
    }
  };
  load();
  return () => { ignore = true; }; // cleanup: ignore stale responses
}, [id]);

3分でできるチェックリスト

  1. F12 → Console: 赤いエラーは出ていない? そのメッセージが本当の原因です。
  2. F12 → Network: 失敗した(赤い)リクエストはない? 404 / 500 / CORS エラーではない?
  3. setLoading(false)finally の中にある? ないなら → 原因1。
  4. res.ok をチェックしている? していないなら → 原因2。
  5. useEffect の依存配列は正しい? 未指定やオブジェクトが入っているなら → 原因3。

> 確実な対処: 原因1〜3 は定番の対処法で、公式の React・MDN ドキュメントで検証済みです。無限スピナーの大半は2行 — 「finally で loading をクリア」+「res.ok をチェック」— で解決します。