React.useとSuspenseを使うと無限フェッチが起こる件


React.useとSuspenseの勉強をしているときに、無限にフェッチが起きる現象に悩まされました。
この原因と解決方法について、備忘録がてら残します。

# 環境

- React 19.0.0
- Next.js 15.3.1

# 無限フェッチが起きるコード

```tsx
"use client";
import { Suspense, useState } from "react";
import User from "./User";

export default function Users() {
	const [userId, setUserId] = useState(1);

	const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
		setUserId(Number(e.target.value));
	};

	return (
		<>
			<div>
				<input type="number" onChange={onChange} defaultValue={1} />
			</div>

			<Suspense fallback={<>Loading...</>}>
				<User id={userId} />
			</Suspense>
		</>
	);
}

```

```tsx
import { cache, use } from "react";
import { User as UserType } from "./types";

const fetchData = cache(async (id: number) => {
	const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
	if (!res.ok) throw new Error("error");

	return res.json();
});

export default function User({ id }: { id: number }) {
	const users: UserType = use(fetchData(id));

	return (
		<div>
			<ul>
				<li>{users.name}</li>
				<li>{users.email}</li>
			</ul>
		</div>
	);
}

```

```tsx
export type User = {
	id: number;
	name: string;
	username: string;
	email: string;
	address: {
		street: string;
		suite: string;
		city: string;
		zipcode: string;
		geo: {
			lat: string;
		};
	};
	company: {
		name: string;
		catchPhrase: string;
		bs: string;
	};
	phone: string;
	website: string;
};

```

### やろうとしたこと

- 特定のidのユーザー情報を取得し、画面に表示する
- idはUsersコンポーネントのinputで指定する
- ユーザー情報をフェッチしている間はSuspenseのfallbackに指定されているもの（`<>Loading...</>`）がUserコンポーネントの代わりに表示される
- Eager loadingは使用せず、inputの値が変更されるたびにユーザー情報のフェッチを行う

### 動かしてみた結果

うまくいかない。
inputの値を変更すると、ずっとLoading…が表示され、ユーザー情報が表示されない。
また、開発者ツールのNetworkを見てみると、リクエストが無限に繰り返されている。

# 原因

Suspenseの子要素でPromiseを生成していたこと。

### Suspenseはいつfallbackを表示するのか

そもそもSuspenseはどのような時にfallbackを表示するのでしょうか。
[公式ドキュメント](https://ja.react.dev/reference/react/Suspense)を参照します。

> `fallback`: 実際の UI がまだ読み込みを完了していない場合に、その代わりにレンダーする代替 UI です。有効な React ノードであれば何でも受け付けますが、現実的には、フォールバックとは軽量なプレースホルダビュー、つまりローディングスピナやスケルトンのようなものです。`children` がサスペンドすると、サスペンスは自動的に `fallback` に切り替わり、データが準備できたら `children` に戻ります。`fallback` 自体がレンダー中にサスペンドした場合、親のサスペンスバウンダリのうち最も近いものがアクティブになります。
> 

> React は、子要素が必要とするすべてのコードとデータが読み込まれるまで、ロード中のフォールバックを表示します。
> 

実際のUI、今回はUserコンポーネントが必要とするデータの読み込みが完了していないと、fallbackが表示されるようです。

Userコンポーネントは内部でfetchData()を呼び出しており、fetchData()はfetch()を呼び出しています。つまり、fetchData()の返り値の型はPromise<UserType>であり、フェッチが完了するまではstatusがpendingのPromiseが返ってきているということになります。

Promiseがpending状態ということは、Userコンポーネントの描画に必要なuserがまだ取得できていないということです。これはまさにSuspenseの子要素が必要とする全てのコードとデータが読み込まれていない状態なので、Promiseが解決されるまで、Suspenseのfallbackが表示されることになります。

### SuspenseはPromiseの解決を検知すると子を再レンダリングする

正確にはSuspense自体がPromiseの状態を直接監視しているわけではありません。Promiseに.then()の形式でコンポーネントの再レンダリングを予約しているというイメージの方が正しいです。
参考：https://iwsr-657.xlog.app/React-Suspense-yuan-ma-jie-xi?locale=en&utm_source=chatgpt.com#user-content-about-the-re-render-triggered-after-the-promise-resolves

内部処理はともかく、ここで重要なことは、Userコンポーネントの描画必要な情報が揃った時、Userコンポーネントは再レンダリングされるということです。

公式ドキュメントに、注意点として以下のことが書かれています。

> React は、初回マウントが成功するより前にサスペンドしたレンダーに関しては、一切の state を保持しません。コンポーネントが読み込まれたときに、React はサスペンドしていたツリーのレンダーを最初からやり直します。
> 

### 無限フェッチの流れまとめ

以上のことから考えると、最初に提示したコードでは以下のことは起きていたのではないかと考えられます。

1. Userが読み込まれる
2. User内でPromiseが生成される（フェッチが行われる）
3. UserはSuspenseの子要素になっているため、Userの描画を一時中断し、fallbackを表示する
4. UserのPromiseが解決する
5. Userが再レンダリングされる
6. User内で新しくPromiseが生成される（フェッチが行われる）
7. 3に戻る…

Promiseが解決されたからUserを再レンダリングしたところ、新たなPromiseが生成されたため、再びUserの描画が中止され、fallbackが表示される。

これが繰り返されていたため、ずっとLoading…が表示され、無限にフェッチが行われていたのです。

# 解決方法

### 解決に至るまでの思考

Suspenseの子要素でPromiseを生成していたことが原因で無限フェッチが起きていたので、これをSuspenseの外に出す必要がありました。そのため、fetchData()を呼び出す場所をUsersコンポーネントに移しました。また、この関係でinputもUsersコンポーネントの移動させました。

あとはユーザー情報のフェッチが完了するまで、ユーザー情報の代わりにLoading…を表示させることができれば成功です。

今回はUserコンポーネントにPromiseを渡すことでこれを実現しました。Promiseオブジェクトを子要素に渡すことはあまり馴染みがないかもしれませんが、[公式ドキュメント](https://ja.react.dev/reference/react/use#usage)にも書かれているやり方です。

これで、Promiseが解決するまでSuspenseのfallbackを表示することができるようになります。

### コード

```tsx
"use client";

import { Suspense, useState, cache } from "react";
import User from "./User";
import { User as UserType } from "./types";

const fetchUser = cache(async (id: number): Promise<UserType> => {
	const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
	if (!res.ok) {
		throw new Error("ユーザーデータの取得に失敗しました");
	}

	return res.json();
});

export default function Users() {
	const [userId, setUserId] = useState(1);

	const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
		setUserId(Number(e.target.value));
	};

	const userPromise = fetchUser(userId);

	return (
		<>
			<div>
				<input type="number" onChange={handleChange} defaultValue={1} min={1} />
			</div>

			<Suspense fallback={<>Loading...</>}>
				<User userPromise={userPromise} />
			</Suspense>
		</>
	);
}
```

```tsx
import { use } from "react";
import { User as UserType } from "./types";

export default function User({
	userPromise,
}: {
	userPromise: Promise<UserType>;
}) {
	const user = use(userPromise);

	return (
		<div>
			{user ? (
				<ul>
					<li>{user.name}</li>
					<li>{user.email}</li>
				</ul>
			) : (
				<>入力されたIDのユーザーは存在しません</>
			)}
		</div>
	);
}

```

### 挙動を確認

inputの値を変更すると、Loading…が表示された後、ユーザ情報が表示されるようになりました！

開発者ツールのNetworkを確認したところ、無限フェッチが起きている様子もありません。大成功です！

# 最後に

今回は、無限フェッチが起きている原因とその解決方法を備忘録がてらご紹介しました。

次はフェッチしたデータをキャッシュして、一度フェッチしたものは再度フェッチしなくてもいいようにしたいと考えています。成功したら記事にするかもしれません。

私はNext.jsやReact.use、Suspenseなどについては、まさに勉強している途中です。間違いあったら遠慮なくご指摘いただけると大変ありがたいです。

# 参考

https://ja.react.dev/reference/react/use

https://ja.react.dev/reference/react/Suspense

https://zenn.dev/sc30gsw/articles/357c0e216e2411

https://zenn.dev/uhyo/articles/react-use-rfc

https://iwsr-657.xlog.app/React-Suspense-yuan-ma-jie-xi?locale=en&utm_source=chatgpt.com