1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScript化したReactアプリを、Next.jsへ移行してみた!

Last updated at Posted at 2024-07-14

はじめに

こんにちは!WEBエンジニア転職を目指しているK.Yです!
今回は、ReactアプリをNext.jsへ移行してみました!

JavascriptとReactで簡易的なブログ作った記事もありますので、そちらもよかったらご覧ください!

前提

ルーターは、App Router
CSSは、tailwindCSS

バージョン
react: 18.3.1,
react-dom: 18.3.1,
react-router-dom: 6.23.1

JavaScript ES6以降

Next.js

Next.jsとは、Reactをベースにしたウェブアプリケーションフレームワークで、
サーバサイドレンダリング(SSR)と静的サイト生成(SSG)を簡単に実現するためのツールです。
これによって、WEBアプリケーションのパフォーマンスが向上し、SEO対策ができます。

Next.jsでは、ページごとにJavaScriptファイルを作成し、そのファイルが自動的にルーティングされます。
また、APIルートを定義することで、バックエンド機能も簡単に追加できます。

App Router

今回は、App Routerの方でNext.jsに移行してみました。

Next.jsには、2種類のルーターがあります。
・Pages Router(pages)
・App Router(app)

App Routerとは、フォルダーベースのルーターで,Next.jsの新しいルーティングシステムです。
従来のpagesディレクトリーと代わって、appディレクトリーを使用します。
大規模なアプリケーションや複雑なルーティング構造に適しています。
公式ドキュメントでもApp Router推奨と謳っています!

App Routerの基本的な使い方

appディレクトリー内にページを作成すると、そのディレクトリー構造がそのままURLパスに反映されます。
例えば、src/app/inquiry/page.tsxと作成すると、inquiryというURLが表示されます!
因みに、page.tsxというのは、Next.jsが特定のファイル命名規則となっていて、
公式ドキュメントでも採用されている。

ディレクトリー構造(App Router)

app内に、ブログの各ページをApp Routerで作ったディレクトリー構造となります!

スクリーンショット 2024-07-13 16.58.15.png

src/app
├── _components/
│   ├── PostsList.tsx
│   ├── DetailsPage.tsx
│   └── InquiryPage.tsx
├── _data/
│   └── posts.ts
├── _styles/
│   └── globals.css
├── details/
│   └── [id]/
│       └── page.tsx
├── inquiry/
│   └── page.tsx
├── layout.tsx
└── page.tsx

・src/app/_components
 複数のページで再利用されるコンポーネントが含まれます。

・src/app/_data/posts.ts
データの管理専用のディレクトリに分けることで、データの取得や管理が簡単になります。

・src/app/_styles/globals.css
全体のスタイルを管理します。
layout.tsxや各ページにインポートするとことで、全ページに共通のスタイルが適用できる。

・src/app/details/[id]/page.tsx (記事詳細ページ)
  動的ルーティング: 角括弧([])を使って動的なURLパラメータを指定します。例えば、/details/123のようなURLに対応します。
データフェッチ: URLパラメータに基づいてデータを取得し、表示することが一般的です。

・src/app/inquiry/page.tsx(お問い合わせフォーム)
静的ルーティング: 固定されたURLパスに対応するページです。例えば、/inquiryというURLにアクセスするとこのページが表示されます。

・src/app/page.tsx(記事一覧ページ)
 アプリケーションのルートページ(トップページ)に対応します。
 page.tsxは、アプリケーションの最初のエントリーポイントとして機能します!

・src/app/layout.tsx
 全てのページに共通するレイアウトを定義するために使用。

記事一覧ページ

"use client";
import PostsList from './_components/PostsList';

const PostListPage: React.FC = () => {
  return (
      <PostsList />
  );
};

export default PostListPage;

・src/app/page.tsx(記事一覧ページ)
use clientは、先頭に入れることで、そのファイルはクライアントサイドで,
実行される(ブラウザ上で実行)ことを明示するため。
宣言することで、パフォーマンスの最適化とコードの明確な区別が可能になります。

上記のコードは、記事のトップページである、PostsListコンポーネントを表示する
ためだけのページとなります!

以下、PostsListコンポーネント(src/app/_components/PostsList.tsx)

src/app/_components/PostsList.tsx(記事一覧ページ)

"use client";
import React from "react";
import { useEffect, useState } from "react";
import  Link  from "next/link";
import "@/app/_styles/globals.css"

type ArticleType = {
  id: number;
  createdAt: string;
  categories: string[];
  title: string;
  content: string;
};

type PostsType = {
  posts: ArticleType[];
};

const PostsList: React.FC = () => {
  const formatDate = (dateString: string) => {
    const date = new Date(dateString);
    const options: Intl.DateTimeFormatOptions = {
      year: "numeric",
      month: "numeric",
      day: "numeric",
    };

    return date.toLocaleDateString("ja-JP", options);
  };

  const [posts, setPosts] = useState<PostsType>({ posts: [] });
  const [loading, setLoading] = useState<boolean>(true); // ローディング状態を追加

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(
          "https://1hmfpsvto6.execute-api.ap-northeast-1.amazonaws.com/dev/posts"
        );
        const data = (await response.json()) as PostsType;

        setPosts(data);
      } finally {
        setLoading(false); // データ取得が完了したらローディングを終了
      }
    };

    fetchData();
  }, []);

  if (loading) {
    return <div>Loading...</div>; // ローディング中の表示
  }

  return (
    <div>
      <header className="bg-neutral-800 flex justify-between py-6 px-7 h-15">
        <Link href="/" className="text-lg font-bold text-white no-underline">
          Blog
        </Link>
        <Link
          href="/inquiry"
          className="text-base font-bold text-white no-underline"
        >
          お問い合わせ
        </Link>
      </header>

      {Array.isArray(posts.posts) &&
        posts.posts.map((article) => (
          <div
            key={article.id}
            className="my-8 m-80 px-6 py-3 text-[16px] border-solid border-[1.8px] border-base-700"
          >
            <ul>
              <li className="p-[10px]">
                <Link href={`/details/${article.id}`}>
                <div className="flex items-center justify-between">
                  <div className="text-xs text-neutral-500">
                    {formatDate(article.createdAt)}
                  </div>
                  <div className="self-start text-right mx-10">
                    {article.categories.map((category, idx) => (
                      <span
                        key={idx}
                        className="m-[6px] p-[6px] text-[13px] border border-solid border-blue-600 rounded text-blue-600"
                      >
                        {category}
                      </span>
                    ))}
                  </div>
                  </div>
                  <div className="text-[25px]">{article.title}</div>
                  <div
                    className="mt-2 pt-2 overflow-hidden"
                    style={{
                      display: "-webkit-box",
                      WebkitBoxOrient: "vertical",
                      WebkitLineClamp: 2,
                    }}
                    dangerouslySetInnerHTML={{ __html: article.content }}
                  ></div>
                </Link>
              </li>
            </ul>
          </div>
        ))}
    </div>
  );
};

export default PostsList;

import Link from "next/link";
Next.jsでは、Linkコンポーネントはreact-router-dom
からインポートするのではなく、next/linkからインポートします!

記事詳細ページ

src/app/details/[id]/page.tsx(詳細ページ)

"use client";
import DetailsPage from "@/app/_components/DetailsPage";
import { useParams } from "next/navigation";

const DetailsArticle = () => {
  const {id} = useParams();
  
  if(!id) {
    return <div>Loading...</div>
  }
  return <DetailsPage id={id} />
};

export default DetailsArticle;

src/app/details/[id]/page.tsx(詳細ページ)
このファイルは、動的な詳細ページを定義しています。
[id]はプレースホルダーで、実際のURLパラメーターがここに入ります。

例えば、http://localhost:3000/details/1にアクセルすると、page.tsxが表示され、id1の詳細情報が表示されます。
これは、動的ルーティングでサポートされていて、特定のアイテムやエントリの詳細を表示させれるために使用されます。

・import { useParams } from "next/navigation";
useParamsフックは、next/navigationからインポートされていて、
  現在のURLパラメータを取得するために使用しています。

const {id} = useParams();
useParamsフックを使ってURLからidパラメータを取得。

return <DetailsPage id={id} />;
DetailsPageコンポーネントをレンダリングし、そのidプロパティに取得したidパラメータを渡しています。これにより、DetailsPageコンポーネントが特定のIDに基づいた詳細情報を表示。

以下、DetailsPageコンポーネント(src/app/_components/DetailsPage.tsx)

src/app/_components/DetailsPage.tsx(記事詳細ページ)

"use client";
import React from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import "@/app/_styles/globals.css";

type detailsType = {
  id: number;
  createdAt: string;
  thumbnailUrl: any;
  categories: string[];
  title: string;
  content: string;
};

type ApiResponse = {
  post: detailsType;
};

interface DetailsPageProps {
  id: string | string[];
}

const DetailsPage: React.FC<DetailsPageProps> = ({ id }) => {
  const formatDate = (dateString: string): string => {
    const date = new Date(dateString);
    const options: Intl.DateTimeFormatOptions = {
      year: "numeric",
      month: "numeric",
      day: "numeric",
    };
    return date.toLocaleDateString("ja-JP", options);
  };

  const [detailsData, setDetailsData] = useState<detailsType>();
  const [loading, setLoading] = useState<boolean>(true); // ローディング状態を追加

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(
          `https://1hmfpsvto6.execute-api.ap-northeast-1.amazonaws.com/dev/posts/${id}`
        );
        const result = (await response.json()) as ApiResponse;

        setDetailsData(result.post);
      } finally {
        setLoading(false); // データ取得が完了したらローディングを終了
      }
    };

    fetchData();
  }, [id]);

  if (loading) {
    return <div>Loading...</div>; // ローディング中の表示
  }

  if (!detailsData) return <div>投稿が見つかりません</div>;

  return (
    <div>
      <header className="bg-neutral-800 flex justify-between py-6 px-7 h-15">
        <Link href="/" className="text-lg font-bold text-white no-underline">
          Blog
        </Link>
        <Link
          href="/inquiry"
          className="text-lg font-bold text-white no-underline"
        >
          お問い合わせ
        </Link>
      </header>

      <div
        style={{ border: "none" }}
        className="my-10 m-80 px-6 py-3 border-solid border-[13px] border-base-700"
      >
        <ul>
          <li key={detailsData.id}>
            <div className="img w-[800px] h-[400px] relative">
              <Image
                src={detailsData.thumbnailUrl}
                alt="img"
                layout="fill"
                objectFit="cover"
              />
            </div>
            <div className="pt-5 flex items-center justify-between">
            <div className="text-xs border-gray-400">
              {formatDate(detailsData.createdAt)}
            </div>
            <div className="self-start text-right mx-10">
              {detailsData.categories.map((category, idx) => (
                <span
                  key={idx}
                  className="m-[6px] p-[6px] text-[13px] border border-solid border-blue-600 rounded text-blue-600"
                >
                  {category}
                </span>
              ))}
            </div>
            </div>
            <div className="text-[25px] mt-2">{detailsData.title}</div>
            <div
              className="block mt-2 text-[16px]"
              dangerouslySetInnerHTML={{ __html: detailsData.content }}
            ></div>
          </li>
        </ul>
      </div>
    </div>
  );
};

export default DetailsPage;

interface DetailsPageProps { id: string | string[]; }
 DetailsPageコンポーネントが受け取るプロパティ(props)を定義しています。

id: string | string[];
 idプロパティは、文字列または文字列の配列を表しています。

const DetailsPage: React.FC<DetailsPageProps> = ({ id }) => { ... }
<DetailsPageProps>は、このコンポーネントがDetailsPageProps型のプロパティを受け取ることを示しています。
({ id }) => { ... }
アロー関数で、idプロパティを受け取り、その値を使って以降の処理を行います。

お問い合わせフォーム

src/app/inquiry/page.tsx(問い合わせフォーム)

"use client";
import InquiryPage from "@/app/_components/InquiryPage";

const Inquiry = () => {
  return <InquiryPage/>
}

export default Inquiry;

上記のコードは、静的なお問い合わせフォームを定義しています。

静的ルーティングでは、app/inquiry/page.tsxファイルが自動的に/inquiryというURLに対応します。Next.jsはこのファイル構造を元にルートを生成し、問い合わせフォームページを表示します。これにより、手動でルーティングを設定する手間が省けます。

以下のコードは、InquiryPageコンポーネント(src/app/_components/InquiryPage.tsx)

src/app/_components/InquiryPage.tsx(お問い合わせフォーム)

"use client";
import React from "react";
import Link from "next/link";
import { FormEvent, useState } from "react";
import "@/app/_styles/globals.css";

type InquiryType = {
  name: string;
  email: string;
  message: string;
};

type ErrorsType = {
  name?: string;
  email?: string;
  message?: string;
};

const InquiryPage: React.FC = () => {
  const [inquiryData, setInquiryData] = useState<InquiryType>({
    name: "",
    email: "",
    message: "",
  });
  const [errors, setErrors] = useState<ErrorsType>({});
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { id, value } = e.target;
    setInquiryData((prevData) => ({ ...prevData, [id]: value }));
  };

  const validate = () => {
    const tempErrors: ErrorsType = {};
    if (!inquiryData.name) tempErrors.name = "お名前は必須です。";
    if (!inquiryData.email) tempErrors.email = "メールアドレスは必須です。";
    if (!inquiryData.message) tempErrors.message = "本文は必須です。";
    setErrors(tempErrors);
    return Object.keys(tempErrors).length === 0;
  };

  const handleSubmit = async (e: FormEvent): Promise<void> => {
    e.preventDefault();
    if (!validate()) return;

    setIsSubmitting(true);

    try {
      const response = await fetch(
        "https://1hmfpsvto6.execute-api.ap-northeast-1.amazonaws.com/dev/contacts",
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(inquiryData),
        }
      );

      if (!response.ok) throw new Error("Network response was not ok");

      alert("送信しました");
      setInquiryData({ name: "", email: "", message: "" });
      setErrors({});
    } catch (error) {
      console.error("Error submitting form:", error);
    } finally {
      setIsSubmitting(false);
    }
  };

  const handleClear = () => {
    setInquiryData({ name: "", email: "", message: "" });
  };
  return (
    <div>
      <header className="bg-neutral-800 flex justify-between py-6 px-7 h-15">
        <Link href="/" className="text-lg font-bold text-white no-underline">
          Blog
        </Link>
        <Link
          href="/inquiry"
          className="text-lg font-bold text-white no-underline"
        >
          お問い合わせ
        </Link>
      </header>

      <div className="w-[800px] mx-auto my-5 p-5">
        <h1 className="text-xl mb-5 font-bold ">問合わせフォーム</h1>
        <form id="myForm" onSubmit={handleSubmit}>
          <div className="mb-4">
            <label>
              <dl>
                <dt className="float-left w-[120px] mt-4">
                  お名前
                </dt>
                <div className="flex flex-col mb-5">
                  <dd>
                    <input
                      type="text"
                      id="name"
                      maxLength={30 as number}
                      value={inquiryData.name}
                      onChange={handleChange}
                      disabled={isSubmitting}
                      className="h-15 float-left"
                    />
                  </dd>
                  {errors.name && <span>{errors.name}</span>}
                </div>
              </dl>
            </label>

            <div className="mb-4">
              <label>
                <dl>
                  <dt className="float-left w-[120px] mt-4 ">メールアドレス</dt>
                  <div className="flex flex-col">
                    <dd>
                      <input
                        type="text"
                        id="email"
                        value={inquiryData.email}
                        onChange={handleChange}
                        disabled={isSubmitting}
                        className="h-15 pl-5"
                      />
                    </dd>
                    {errors.email && <span>{errors.email}</span>}
                  </div>
                </dl>
              </label>
            </div>
            <div className="mb-4">
              <label>
                <dl>
                  <dt className="float-left w-[120px] mt-[120px]">本文</dt>
                  <div className="flex flex-col">
                    <dd>
                      <textarea
                        id="message"
                        maxLength={500 as number}
                        value={inquiryData.message}
                        onChange={handleChange}
                        disabled={isSubmitting}
                        rows={10 as number}
                        className="w-full box-border border border-gray-300 rounded-lg mb-2.5 p-5"
                      />
                    </dd>
                    {errors.message && <span>{errors.message}</span>}
                  </div>
                </dl>
              </label>
            </div>
          </div>
          <div className="text-center mt-10">
            <input
              type="submit"
              value="送信"
              disabled={isSubmitting}
              className="border border-gray-300
                      rounded m-0 mx-2 p-2 px-4
                      text-base font-bold
                      bg-blue-800 text-white"
            />
            <input
              type="reset"
              value="クリア"
              onClick={handleClear}
              disabled={isSubmitting}
              className="border border-gray-300
                      rounded m-0 mx-2 p-2 px-4
                      text-base font-bold
                      bg-gray-300"
            />
          </div>
        </form>
      </div>
    </div>
  );
};

export default InquiryPage;

ポイント

Next.js

フレームワーク
Next.jsは、Reactペースのフレームワーク。

レンダリング
サーバーサイドレンダリング(SSR)と静的サイト生成(SSG)をサポート。

パフォーマンス
高速なページロードと優れたSEO対策。

App Router

新しいルーティングシステム
pagesディレクトリに代わり、appディレクトリを使用。

ファイル構造
ディレクトリ構造がそのままURLパスに反映。

動的ルーティング
[id]のようなプレースホルダーで動的URLをサポート。

レイアウト
layout.tsxで共通レイアウトを簡単に設定。

クライアント実行
use client"でクライアントサイド実行を明示。

おわり

Next.jsは、ディレクトリ構造に基づいてURLパスが自動生成されるため、
手動でのルート設定の手間が省けます!
とても便利ですし、開発効率が上がり、コードの可読性や保守性が高まります!

1
0
0

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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?