この症状に当てはまりますか?
- スピナーが回り続けてページが表示されない
- 'Loading...' の文字がいつまでも消えない
- 更新しても何も変わらない
- DevTools(F12)の Console に赤いエラーが出ている、または Network タブにリクエスト失敗(赤)が表示される
無限スピナーは、ほとんどの場合まったく同じことを意味します。つまり、データの読み込みが最後まで終わらず、loading フラグが一度もオフにされていないのです。よくある原因は4つ — そのほとんどは2行のコードで直ります。
原因1 — リクエスト失敗時に loading がクリアされない(最も多い)
よくあるコード:
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 フラグのクリアのような後処理を置く場所として最適です。
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つのミス:
useEffect(() => { /* ... */ }); // 🚩 no array → runs after every render
useEffect(() => { /* ... */ }, [options]); // 🚩 options is an object → new on every render
React 公式ドキュメント: オブジェクト・配列・関数は毎回のレンダリングで作り直されるため、依存配列に入れるとコミットのたびに値が変わり、無限ループになります。
対処法
- 一度だけ実行する → 空の配列
[]を渡す:useEffect(() => { ... }, []); - オブジェクトや関数は Effect の内側で作り、依存配列にはプリミティブ(文字列・数値)だけを残す:
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 フラグを使います:
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分でできるチェックリスト
- F12 → Console: 赤いエラーは出ていない? そのメッセージが本当の原因です。
- F12 → Network: 失敗した(赤い)リクエストはない? 404 / 500 / CORS エラーではない?
setLoading(false)は finally の中にある? ないなら → 原因1。res.okをチェックしている? していないなら → 原因2。useEffectの依存配列は正しい? 未指定やオブジェクトが入っているなら → 原因3。
> 確実な対処: 原因1〜3 は定番の対処法で、公式の React・MDN ドキュメントで検証済みです。無限スピナーの大半は2行 — 「finally で loading をクリア」+「res.ok をチェック」— で解決します。
