【Next.js】RSC + Suspenseで、ユーザー操作に伴うデータフェッチと表示を試してみた【App Router】

# はじめに

Next.js App Routerに初めて触れることになり、ドキュメントを読んでいて、色々試したくなったので着手しました。

サーバーコンポーネントでのデータフェッチには、パフォーマンス向上やキャッシュ最適化などのメリットがあります。ですが、実際実装になるとクライアントコンポーネントとの使い分けが複雑になり、頭を悩ませることになります。
無理のない範囲で、ユーザーの操作に対してのデータフェッチをどこまでサーバーコンポーネントに寄せることができるのか気になり、試してみました。

練習に近いような内容ですが、どなたかの役に立つかもと思い、投稿させていただきます🙇

# 前提

PokeAPIを使って、フォームの提出値を元にドット絵を取得し表示する簡単なアプリを作っていきます。
リポジトリは以下です↓
https://github.com/hagoromo2000/next.js-sandbox

 作成するUIコンポーネントは、以下のような親子関係になります。

```
page（サーバーコンポーネント）/
├── PokemonForm(クライアントコンポーネント、フォーム部分）
└── PokemonImage（サーバーコンポーネント、画像の表示部分）
```

準備部分

1. MUIで簡単なフォームのUIを作成
2. React Hook Formでフォーム管理
3. zodでバリデーション

実践部分
1. クライアントコンポーネントでクエリパラメータのセット
2. サーバーコンポーネントでデータフェッチ + 表示

**ユーザーのフォーム提出をトリガーにし、サーバーコンポーネントでデータフェッチし、Suspenseを使用しローティングUIを表示して、画像の表示まで行う。** が目標です。


## 今回使用するバージョン

```tsx
├── @emotion/react@11.13.3
├── @emotion/styled@11.13.0
├── @hookform/resolvers@3.9.0
├── @mui/material@5.16.7
├── @types/node@20.16.1
├── @types/react-dom@18.3.0
├── @types/react@18.3.4
├── eslint-config-next@14.2.6
├── eslint@8.57.0
├── next@14.2.6
├── react-dom@18.3.1
├── react-hook-form@7.53.0
├── react@18.3.1
├── typescript@5.5.4
└── zod@3.23.8
```

# 準備

## プロジェクト作成

```
npx create-next-app@latest
```

### ライブラリのインストール

- [MUI](https://mui.com/material-ui/getting-started/installation/)
    
    ```tsx
    npm install @mui/material @emotion/react @emotion/styled
    ```
    
    記事執筆時点でMUIv6がリリースされていますが、Gridレイアウトに大きな変更が加わっているので、当記事ではv5を使用しています。
    
- [React Hook Form](https://react-hook-form.com/get-started)
    
    ```tsx
    npm install react-hook-form
    ```
    
- z[od](https://zod.dev/?id=installation)
    
    ```tsx
    npm install zod
    ```
    
- [resolver](https://github.com/react-hook-form/resolvers?tab=readme-ov-file#install)
    
    ```tsx
    npm install @hookform/resolvers
    ```
    
    （zodで作成したスキーマをreact hook formに適用するために使います）
    

## MUIで簡単なフォームのUIを作成する

Gridレイアウトを主に使用。*注意：GridはMUIv6では非推奨です。代わりにGrid2があります。

### `src/app/pokemon/page.tsx`

```tsx
import { Grid } from "@mui/material";
import PokemonForm from "./_components/pokemon-form";

export default function Pokemon() {
  return (
    <Grid container direction="column" spacing={6}>
      <Grid item>
        <PokemonForm />
      </Grid>
    </Grid>
  );
}
```

### `src/app/pokemon/_components/pokemon-form.tsx`

```tsx
import {
  Button,
  FormControl,
  FormHelperText,
  Grid,
  MenuItem,
  Select,
  TextField,
} from "@mui/material";

export default function PokemonForm() {
  return (
    <Grid
      container
      component="form"
      spacing={2}
    >
      <Grid item xs={6}>
        <FormControl fullWidth>
           <TextField
               id="id"
               variant="outlined"
               type="number"
            />
        </FormControl>
      </Grid>
      <Grid item xs={6}>
        <FormControl fullWidth>
          <Select>
             <MenuItem value="front_default">正面</MenuItem>
             <MenuItem value="back_default">背面</MenuItem>
             <MenuItem value="front_shiny">正面_色違い</MenuItem>  
             <MenuItem value="back_shiny">背面_色違い</MenuItem>
          </Select>
          <FormHelperText>すがたを選択してください</FormHelperText>
        </FormControl>
      </Grid>
      <Grid item xs={12}>
        <Grid container alignItems="center" justifyContent="center">
          <Button variant="contained" color="primary" type="submit">
            画像を表示
          </Button>
        </Grid>
      </Grid>
    </Grid>
  );
}

```
現時点で以下のようになるはず。
![スクリーンショット 2024-08-31 11.04.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3290437/7fc745f1-edb1-936d-c875-4830bcc47f1a.png)

## zodでスキーマを作成する

### `src/types/schema/pokemon-form-schema.ts`

```tsx
import z from "zod";

export const pokemonSchema = z.object({
  id: z.coerce // 数値を扱うときは、coerceを利用することで、数値を強制してあげる必要がある。そうしないとstringで扱われてエラーになる https://zenn.dev/yuki_tu/scraps/194e4813ef03db
    .number()
    .min(1, "1~1025で入力してください。")
    .max(1025, "1~1025で入力してください。")
    .default(1),
  sprite: z
    .enum(["front_default", "back_default", "front_shiny", "back_shiny"])
    .default("front_default"),
});

export type PokemonSchema = z.infer<typeof pokemonSchema>;

```

数値を扱うときは、`coerce`を利用することで、数値を強制してあげる必要があります。（そうしないとstringで扱われてエラーになる）

## ReactHookFormでフォームの値を管理する

`src/app/pokemon/_components/pokemon-form.tsx`

```tsx
"use client";
import { useSafeForm } from "@/hooks/use-safe-form";
import {
  PokemonSchema,
  pokemonSchema,
} from "@/types/schema/pokemon-form-schema";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  Button,
  FormControl,
  FormHelperText,
  Grid,
  MenuItem,
  Select,
  TextField,
} from "@mui/material";
import { useRouter } from "next/navigation";
import { Controller } from "react-hook-form";

export default function PokemonForm() {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useSafeForm<PokemonSchema>({ // https://zenn.dev/yuitosato/articles/292f13816993ef
    mode: "onChange",
    resolver: zodResolver(pokemonSchema),
    defaultValues: {
      id: 1,
      sprite: "front_default",
    },
  });

  const router = useRouter();

  const usePokemonFormSubmit = (data: PokemonSchema) => {
    console.log(data);
    router.push(`/pokemon?id=${data.id}&sprite=${data.sprite}`);
  };

  return (
    <Grid
      container
      component="form"
      spacing={2}
      onSubmit={handleSubmit(usePokemonFormSubmit)}
    >
      <Grid item xs={6}>
        <FormControl fullWidth>
          <Controller
            name="id"
            control={control}
            render={({ field }) => (
              <TextField
                {...field}
                id="id"
                variant="outlined"
                type="text"
                inputProps={{
                  inputMode: "numeric",
                  pattern: "[1-9][0-9]*",
                }}
                onChange={(e) => {
                  const value = e.target.value;
                  if (value === "" || /^[1-9][0-9]*$/.test(value)) {
                    field.onChange(value);
                  }
                }}
                error={!!errors.id}
                helperText={
                  errors.id ? errors.id.message : "図鑑Noを選択してください"
                }
              />
            )}
          />
        </FormControl>
      </Grid>
      <Grid item xs={6}>
        <FormControl fullWidth>
          <Controller
            name="sprite"
            control={control}
            defaultValue="front_default"
            render={({ field }) => (
              <Select {...field} id="sprite">
                <MenuItem value="front_default">正面</MenuItem>
                <MenuItem value="back_default">背面</MenuItem>
                <MenuItem value="front_shiny">正面_色違い</MenuItem>
                <MenuItem value="back_shiny">背面_色違い</MenuItem>
              </Select>
            )}
          />
          <FormHelperText>すがたを選択してください</FormHelperText>
        </FormControl>
      </Grid>
      <Grid item xs={12}>
        <Grid container alignItems="center" justifyContent="center">
          <Grid item>
            <Button variant="contained" color="primary" type="submit">
              画像を表示
            </Button>
          </Grid>
        </Grid>
      </Grid>
    </Grid>
  );
}

```

MUIの`TextField`は`register`でも値を管理できますが、今回は管理方法を統一したく、`Controller`を用います。

# 実践

ユーザーの操作に基づくデータフェッチは、`Server Actions`と[`useActionState()`](https://react.dev/reference/react/useActionState)を利用する方法があるらしいですが、Canary版のみの機能なので、今回は使用しません。

以下の手順を行えるようにしていきます。

1. クライアントコンポーネントのフォームの提出値をもとに、クエリパラメータを変更。
2. サーバーコンポーネントでクエリパラメータをもとにデータフェッチ
3. フェッチ中はローディングUIを表示し、完了したら画像を表示

## クライアントコンポーネントの実装

### `src/app/pokemon/_components/pokemon-form.tsx`

```tsx
export default function PokemonForm() {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useSafeForm<PokemonSchema>({
    mode: "onChange",
    resolver: zodResolver(pokemonSchema),
    defaultValues: {
      id: 1,
      sprite: "front_default",
    },
  });

  const router = useRouter();
  const usePokemonFormSubmit = (data: PokemonSchema) => { // 追加
    console.log(data);
    router.push(`/pokemon?id=${data.id}&sprite=${data.sprite}`);
  };

  return (
    <Grid
      container
      component="form"
      spacing={2}
      onSubmit={handleSubmit(usePokemonFormSubmit)} // 追加
    >
```

あまり工夫はなく、router.pushでフォームの提出値を元にクエリパラメータをセットしています。

## サーバーコンポーネントの実装

### `src/app/pokemon/page.tsx`

```tsx
import { PokemonSchema } from "@/types/schema/pokemon-form-schema";
import { CircularProgress, Grid } from "@mui/material";
import { Suspense } from "react";
import PokemonForm from "./_components/pokemon-form";
import PokemonImage from "./_components/pokemon-image";

type Props = {
  searchParams: {
    id?: string;
    sprite?: PokemonSchema["sprite"];
  };
};

export default function Pokemon({ searchParams }: Props) {
  const id = searchParams.id ?? "1";
  const sprite = searchParams.sprite ?? "front_default";

  return (
    <Grid container direction="column" spacing={6}>
      <Grid item>
        <PokemonForm />
      </Grid>
      <Grid item>
        <Grid
          container
          direction="column"
          alignItems="center"
          justifyContent="center"
        >
          <Grid item>
            <Suspense
              key={JSON.stringify(searchParams)}
              fallback={<CircularProgress />}
            >
              {/* @ts-expect-error Server Component https://qiita.com/joinus_ibuki/items/f6c5692496b50d835315*/}
              <PokemonImage id={id} sprite={sprite} />
            </Suspense>
          </Grid>
        </Grid>
      </Grid>
    </Grid>
  );
}

```
参考: [**URLクエリパラメータによる検索画面をSuspenseで実装する3パターン**](https://zenn.dev/frontendflat/articles/nextjs-suspense-use-transition)

ページコンポーネントです。`useSearchParams`はクライアントコンポーネントでしか使えないので、[pageが持っているsearchParamsプロップス](https://ja.next-community-docs.dev/docs/app-router/api-reference/file-conventions/page)からURLクエリパラメータにアクセスしています。

SuspenseにKeyを付与することで、URLクエリパラメータの変更によりSuspenseによるローダー表示が再実行されるようにしています。

```tsx
　　　　	<Suspense
          key={JSON.stringify(searchParams)}
          fallback={<CircularProgress />}
        >
```



### `src/app/pokemon/_components/pokemon-image.tsx`

```tsx
import { pokemonResponse } from "@/types/pokemon-response";
import { PokemonSchema } from "@/types/schema/pokemon-form-schema";
import { Box } from "@mui/material";
import Image from "next/image.js";
import { fetchPokemonData } from "../_utils/fetch-pokemon-data";

type Props = {
  id: string;
  sprite: PokemonSchema["sprite"];
};

const PokemonImage = async (props: Props) => {
  const pokemonImageData: pokemonResponse = await fetchPokemonData(props.id);

  return (
    <Box>
      {pokemonImageData ? (
        <>
          <p>選択した図鑑番号のポケモン</p>
          <Image
            src={pokemonImageData.sprites[props.sprite]}
            alt="ポケモン"
            width={200}
            height={200}
          />
        </>
      ) : (
        <p>画像が見つかりません</p>
      )}
    </Box>
  );
};

export default PokemonImage;

```

画像表示部分を持つコンポーネントです。非同期コンポーネントです。

### `src/app/pokemon/_utils/fetch-pokemon-data.ts`

```tsx
import fetcher from "@/lib/fetcher";

export const fetchPokemonData = (id: string) => {
  const endpoint = `https://pokeapi.co/api/v2/pokemon/${id}`;
  const result = fetcher(endpoint);
  return result;
};

```

`src/lib/fetcher.ts`

```tsx
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const fetcher = async (url: string) => {
  // 擬似的な遅延を追加
  await sleep(2000);

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error("Network response was not ok");
  }
  const res = await response.json();

  return res;
};

export default fetcher;
```

データフェッチ部分です。ローディング検証のため遅延含ませてます。
# 結果
[![Image from Gyazo](https://i.gyazo.com/67c54598a8172a543b182587ce41cf32.gif)](https://gyazo.com/67c54598a8172a543b182587ce41cf32)

初回リクエスト時はロードの発生、すでに表示したことのある項目はキャッシュが活きているのを確認できます。

# 最後に

Suspense + RSCのアプローチは、単純に制御が複雑になってしまうほか、ハイドレーションや型互換の問題を解決するのが難しいです。（今後のアップデートで解決するかも…?）

データフェッチのパフォーマンスなど、RSCのメリットを享受できなくはなるものの、 ケース次第でSuspense + CCでの実装を、検討する必要があるなと思いました。

今回はデータ取得だけを行いましたが、データ操作してその結果を再取得などのパターンになるとさらに複雑になりますしね…。

# おまけ 解決できなかったTypeScriptのエラーについて

```tsx
        <Suspense
          key={JSON.stringify(searchParams)}
          fallback={<CircularProgress />}
        >
          {/* @ts-expect-error Server Component https://qiita.com/joinus_ibuki/items/f6c5692496b50d835315c */}
          <PokemonImage id={id} sprite={sprite} />
        </Suspense>
```

~~`{/* @ts-expect-error Server Component */}` については、TypeScriptの型との互換性に起因するエラーを逃すためにやむを得ず配置しています。ちゃんとした対処法あればどなたかご教授願いたいです🙇~~

【参考】

 https://qiita.com/joinus_ibuki/items/f6c5692496b50d835315

> https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating
> 
> 
> > To use async/await in a Server Component with TypeScript, you'll need to use TypeScript 5.1.3 or higher and @types/react 18.2.8 or higher.
> > 
> 
> 現在の公式にはこのようにあるので、原因はここかも？
> 

[2024/9/4 追記]
コメントでご指摘いただき、tsconfig.jsonを以下のように修正したところ、コンパイルエラー解消しました！
``` json
{
  "compilerOptions": {
    "module": "ESnext",
    "moduleResolution": "Bundler", // 元はNode16
    }
}
```
軽く調べたところ、厳密なNode16に対し、Bundlerはより柔軟なモジュール解決戦略を採用しており、フレームワークを使用したフロントエンド開発にはこちらが適しているらしいです。また別記事でまとめようと思います。

また、lazyを使用しても解決する様子。（Promiseの仕様が明示的になるから？）
https://ja.react.dev/reference/react/lazy

```
import { PokemonSchema } from "@/types/schema/pokemon-form-schema";
import { CircularProgress, Grid } from "@mui/material";
import { Suspense, lazy } from "react";
import PokemonForm from "./_components/pokemon-form";

const LazyPokemonImage = lazy(() => import("./_components/pokemon-image")); // lazy

type Props = {
  searchParams: {
    id?: string;
    sprite?: PokemonSchema["sprite"];
  };
};

export default function Pokemon({ searchParams }: Props) {
  const id = searchParams.id ?? "1";
  const sprite = searchParams.sprite ?? "front_default";

  return (
    <Grid container direction="column" spacing={6}>
      <Grid item>
        <PokemonForm />
      </Grid>
      <Grid item>
        <Grid
          container
          direction="column"
          alignItems="center"
          justifyContent="center"
        >
          <Grid item>
            <Suspense
              key={JSON.stringify(searchParams)}
              fallback={<CircularProgress />}
            >
              <LazyPokemonImage id={id} sprite={sprite} />
            </Suspense>
          </Grid>
        </Grid>
      </Grid>
    </Grid>
  );
}

```

初めて知ったのですが、lazyは特定のコンポーネントの読み込みを意図的に遅延させる機能で、
- 多くのコンポーネントがある場合、優先度の低いコンポーネントに対して使用することで、初期ロード時間を短縮できる
- めったに使用されないコンポーネントや、特定の条件下でのみ表示されるコンポーネントを遅延させる

などのケースで採用されるそうです。