wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

Next.jsでApp Routerに移行してみる

今までPages Routerだった処理サイトをApp Routerに移行してみたの記事にしてみた

nextjs.org

PR

github.com

実装

App Routerに移行する場合は、以下を確認してディレクトレリを変更する必要がある

nextjs.org

基本はsrc/pages/schedule/[id].tsxsrc/app/schedule/[id]/page.tsxみたいな感じでディレクトレリを変更すればOK 👌

App RouterReact Server Components(RSC)でレンダリングされているので対応、以下のようなコードになった

src/app/schedule/[id]/page.tsx

import { getServerAuthSession } from "~/server/auth";
import { redirect } from "next/navigation";
import { Template } from "~/app/_components/schedule/template";
import { api } from "~/trpc/server";

export default async function Page({ params }: { params: { id: string } }) {
  const session = await getServerAuthSession();

  const url = await api.url.exists.query({ id: params.id });
  if (url === false) {
    // 存在しないURLの場合はトップページに戻す
    redirect("/");
  }

  const schedules = await api.schedule.fetch.query({ urlId: params.id });

  return <Template login={!!session} schedules={schedules} id={params.id} />;
}

src/app/_components/schedule/template.tsx

"use client";

import { signIn, signOut } from "next-auth/react";
import { useEffect, useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import Period from "~/features/schedules/components/Period";
import Items from "~/features/schedules/components/Items";
import dayjs from "~/utils/dayjs";
import { monthItems, getScheduleInMonth } from "~/utils/schedule";
import Pagination from "~/features/schedules/components/Pagination";
import { toast } from "react-toastify";
import Layout from "~/components/Layout/Layout";
import { type Schedule } from "@prisma/client";

type Props = {
  login: boolean;
  id: string;
  schedules: Schedule[];
};

export function Template(props: Props) {
  const router = useRouter();

  const [startDate, setStartDate] = useState(dayjs());
  const [print, setPrint] = useState(false);

  const months = monthItems(
    Number(startDate.format("M")),
    Number(startDate.year()),
  );

  const onLogout = useCallback(async () => {
    window.localStorage.setItem("URL_ID", "");
    await signOut({
      callbackUrl: "/",
    });
  }, []);

  const onToIndex = useCallback(() => {
    window.localStorage.setItem("URL_ID", "");
    router.push("/");
  }, [router]);

  const onShare = useCallback(async () => {
    await global.navigator.clipboard.writeText(`${window.location.href}/share`);
    toast.success("URLをコピーしました!", {
      position: "top-center",
      autoClose: 2000,
      hideProgressBar: false,
      closeOnClick: true,
      pauseOnHover: true,
      draggable: true,
      progress: undefined,
      theme: "light",
    });
  }, []);

  const onPrint = useCallback(() => {
    setPrint(true);
    setTimeout(() => window.print(), 100);
  }, []);

  useEffect(() => {
    const handleAfterPrint = () => {
      setPrint(false);
    };

    window.addEventListener("afterprint", handleAfterPrint);

    return () => {
      window.removeEventListener("afterprint", handleAfterPrint);
    };
  }, []);

  return (
    <Layout>
      <>
        <main className="screen-container container mx-auto max-w-screen-xl gap-12 pt-3">
          <div className="no-print">
            <div className="absolute right-28 top-0 hidden sm:block">
              <div
                className="flex w-14 cursor-pointer flex-col items-center pt-3 text-xl hover:bg-blue-100"
                onClick={() => void onShare()}
              >
                🔗
                <div className="text-xxs text-center text-gray-500">シェア</div>
              </div>
            </div>
            <div
              className="absolute right-14 top-0 hidden sm:block"
              onClick={() => void onPrint()}
            >
              <div className="flex w-14 cursor-pointer flex-col items-center pt-3 text-xl hover:bg-blue-100">
                🖨️
                <div className="text-xxs text-center text-gray-500">印刷</div>
              </div>
            </div>
            <div
              className="absolute right-0 top-0 hidden sm:block"
              onClick={() => {
                if (props.login) {
                  void onLogout();
                } else {
                  void signIn("credentials", {
                    callbackUrl: "/",
                  });
                }
              }}
            >
              <div className="flex w-14 cursor-pointer flex-col items-center pt-3 text-xl hover:bg-blue-100">
                {props.login ? "🔓" : "🗝️"}
                <div className="text-xxs text-center text-gray-500">
                  {props.login ? "ログアウト" : "ログイン"}
                </div>
              </div>
            </div>
          </div>
          <div className="relative hidden justify-between sm:flex">
            <Period
              startDate={startDate.format()}
              endDate={startDate.add(1, "years").format()}
            />
            <Pagination
              onNext={() => setStartDate(startDate.add(1, "months"))}
              onPrev={() => setStartDate(startDate.subtract(1, "months"))}
            />
          </div>
          <div className="flex flex-col flex-nowrap justify-center pt-10 sm:flex-row sm:flex-wrap sm:justify-start">
            {months.map((item, index) => (
              <div
                key={index}
                className="item-container px-0 pb-6 sm:pb-16 sm:pr-16"
              >
                <Items
                  urlId={props.id}
                  date={item}
                  defaultItems={getScheduleInMonth(item, props.schedules ?? [])}
                  share={print}
                />
              </div>
            ))}
          </div>
          <div className="no-print mb-10 flex justify-center">
            {props.login ? (
              <button
                type="button"
                className="mb-1 mr-1 rounded-md border border-red-700 px-5 py-2.5 text-center text-sm font-medium text-red-700 hover:bg-red-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-red-300 dark:border-red-500 dark:text-red-500 dark:hover:bg-red-500 dark:hover:text-white dark:focus:ring-red-800"
                onClick={() => void onLogout()}
              >
                ログアウト
              </button>
            ) : (
              <button
                type="button"
                className="mb-1 mr-1 rounded-md border border-gray-700 px-5 py-2.5 text-center text-sm font-medium text-blue-700 hover:bg-blue-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-300 dark:border-gray-500 dark:text-gray-500 dark:hover:bg-gray-500 dark:hover:text-white dark:focus:ring-gray-800"
                onClick={() => void onToIndex()}
              >
                トップページに戻る
              </button>
            )}
          </div>
        </main>
      </>
    </Layout>
  );
}

修正ポイントとしては以下の部分になる

RSCだとawaitが使用できるので以下のようなコードを書くことができる

export default async function Page({ params }: { params: { id: string } }) {
  const session = await getServerAuthSession();

  const url = await api.url.exists.query({ id: params.id });
  if (url === false) {
    // 存在しないURLの場合はトップページに戻す
    redirect("/");
  }

  const schedules = await api.schedule.fetch.query({ urlId: params.id });

  return <Template login={!!session} schedules={schedules} id={params.id} />;

useEffectuseStateに関してはRSCだと使用できないので、使用する場合はファイルの先頭でuse clientを宣言してClient Componentでimportする必要がある

"use client";

import { signIn, signOut } from "next-auth/react";
import { useEffect, useCallback, useState } from "react";

変更して見た感じの感想ですが、RSCにすることによって初回レンダリング時の認証やデータ取得のコードはシンプルになった、変更前と変更後のコードを比較すると以下の通り

実装内容はDBのUrlのtableにアクセスして該当のデータがなけばTOPページにリダイレクト & Urlのデータに認証情報が設定している場合に一致しない場合はTOPページにリダイレクト

■ 変更前

function Schedule() {
  const { data: sessionData } = useSession();
  const router = useRouter();
  const { id } = router.query;
  const url = api.url.exists.useQuery({ id: String(id) });

  useEffect(() => {
    if (url.data === false) {
      // 存在しないURLの場合はトップページに戻す
      void router.push("/");
    }

    if (url.data.userId != "" && url.data.userId != sessionData?.user?.id) {
      // userId設定のあるURLで認証情報が一致しない場合はトップページに戻す
      void router.push("/");
    }
  }, [url.data, router, sessionData?.user?.id]);

■ 変更後

export default async function Page({ params }: { params: { id: string } }) {
  const session = await getServerAuthSession();

  const url = await api.url.exists.query({ id: params.id });
  if (url === null) {
    // 存在しないURLの場合はトップページに戻す
    redirect("/");
  }

  if (url.userId != "" && url?.userId !== session?.user?.id) {
    // URLのユーザーIDとログインしているユーザーIDが一致しない場合はトップページに戻す
    redirect("/");
  }

こんな感じでuseEffectを使わなくても済むのでシンプルになるかなと思います。
逆にuseEffectuseStateを使いたい画面では必ずcomponentを分割してuse clientを宣言しないといけないのが手間だとも感じたので、今のところ凄い大きなメリットがある感じでも無いですが、App Routerじゃないと使用できない機能があったので、今回は移行してみました。