15
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-08-31

はじめに

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を表示して、画像の表示まで行う。 が目標です。

今回使用するバージョン

├── @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

    npm install @mui/material @emotion/react @emotion/styled
    

    記事執筆時点でMUIv6がリリースされていますが、Gridレイアウトに大きな変更が加わっているので、当記事ではv5を使用しています。

  • React Hook Form

    npm install react-hook-form
    
  • zod

    npm install zod
    
  • resolver

    npm install @hookform/resolvers
    

    (zodで作成したスキーマをreact hook formに適用するために使います)

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

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

src/app/pokemon/page.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

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

zodでスキーマを作成する

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

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

"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のTextFieldregisterでも値を管理できますが、今回は管理方法を統一したく、Controllerを用います。

実践

ユーザーの操作に基づくデータフェッチは、Server ActionsuseActionState()を利用する方法があるらしいですが、Canary版のみの機能なので、今回は使用しません。

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

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

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

src/app/pokemon/_components/pokemon-form.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

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パターン

ページコンポーネントです。useSearchParamsはクライアントコンポーネントでしか使えないので、pageが持っているsearchParamsプロップスからURLクエリパラメータにアクセスしています。

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

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

src/app/pokemon/_components/pokemon-image.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

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

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

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

最後に

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

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

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

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

        <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://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を以下のように修正したところ、コンパイルエラー解消しました!

{
  "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は特定のコンポーネントの読み込みを意図的に遅延させる機能で、

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

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

15
10
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?