Open37

Next.js ToDo管理アプリ

アライ リョータアライ リョータ
Next.jsプロジェクトの新規作成
$ npx create-next-app@latest

✔ What is your project named? … .
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
Creating a new Next.js app in /Users/arairyota/workspace/next-example.

Using npm.

Initializing project with template: app-tw 


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next

npm WARN deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm WARN deprecated @humanwhocodes/config-array@0.11.14: Use @eslint/config-array instead
npm WARN deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
npm WARN deprecated @humanwhocodes/object-schema@2.0.3: Use @eslint/object-schema instead
npm WARN deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported

added 362 packages, and audited 363 packages in 14s

137 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
Success! Created next-example at /Users/arairyota/workspace/next-example
アライ リョータアライ リョータ

App Router

App Routerのルーティングの基本ルール
appディレクトリ
- page.tsx -> /
- taskディレクトリ
    - page.tsx -> /task
    - editディレクトリ
        - page.tsx -> /task/edit
        - [id]ディレクトリ
            - page.tsx -> /task/edit/1, task/edit/2:動的ルーティング
  • appディレクトリを起点として、その配下のファイルをルーティングとみなす
  • app直下のpage.tsxはルートとなる
  • app配下のディレクトリ内にpage.tsxを配置すると、そのディレクトリ名を冠したルーティングが生成される
  • app配下のディレクトリ内にさらにディレクトリとpage.tsxを配置すると、ネストしたディレクトリ名のルーティングが生成される
  • [id]ディレクトリを利用することで、動的ルーティングが生成される
page.tsx
const page = () => {
  return (
    <div>
      Hello, Nexr.js!
    </div>
  )
}

export default page
  • page.tsxは、default exportしなければならない(named exportは不可)
Unhandled Runtime Error
Error: The default export is not a React Component in page: "/"
動的ルーティング
task/edit/[id]/page.tsx
import React from 'react'

const TaskEditIdPage = ({ params }: { // 指定したidのパスはコンポーネントでparamsとして取得できる
  params: { id: string }
}) => {
  return (
    <div>
      TaskEditIdPage/{ params.id } // idに応じて動的に出力
    </div>
  )
}

export default TaskEditIdPage
TaskEditIdPage
ルートグループ

^ プロジェクトの規模が大きくなってきた時に、機能や性質毎に新たにディレクトリを作成して整理したい
🚨 Next.jsの通常の振る舞いでは、ディレクトリ界周防が増えることでパスが変わってしまう
-> パスに反映させたくないディレクトリを()で囲むだけでOK

アライ リョータアライ リョータ

layout.tsx

👉 配置したディレクトリに配下に対してレイアウトを適用できる

  • app直下:アプリケーション全体に共通するレイアウト
  • app/task直下:task配下に共通するレイアウト
  • (ルートグループ)直下:ルートグループ内に共通するレイアウト
アライ リョータアライ リョータ

エラーハンドリング

  • 存在しないURLにアクセスしようとした場合、デフォルトの404ページが表示される
カスタマイズした404ページを表示したい場合
  • app直下にnot-found.tsxを作成する
    -> 作成後は、存在しないURLにアクセスすると、not-found.tsxの内容が描画される
app/not-found.tsx
const NotFound = () => {
  return (
    <div>
      NotFoundPage
    </div>
  )
}

export default NotFound
  • error.tsxを作成する
    • ネストも可能なため、複数のerror.tsxが存在する場合、最も近いErrorコンポーネントが描画される
app/error.tsx
'use client' 

const ErrorPage = () => {
  return (
    <div>
      ErrorPage
    </div>
  )
}

export default ErrorPage

アライ リョータアライ リョータ

Client Components(CC)

  • 従来のReactコンポーネントと同様
  • クライアント側でレンダリングが行われる
  • 'use client`を記述することでCCとなる

Server Components(SC)

  • Next.js v13.4から安定板として、SCをデフォルトして採用
  • サーバー側でレンダリングされる

SCのメリット

  • 思い処理をサーバー側に任せることによるパフォーマンス向上
  • 不要なJSライブラリをクライアント側へ送る必要がなくなることにより。JSのバンドルサイズを縮小
  • トークンやAPIキー等の機密データをクライアントに後悔しない
  • 検索エンジンの最適化(SEO)

SCのデメリット

  • CCでしか使用できない機能がある
    • useState等のHooks
    • onClick等のユーザーイベント
    • ブラウザAPI
  • 初期ページ以外はCCの方が高速となる可能性がある

CC, SCの使い分け

  • 基本はSC推奨
  • 以下の場面ではCCを使用
    • Hooksが必要な場合
    • onClickやおnChange等のユーザーイベントを扱う場合
    • ブラウザAPIが必要な場合

CCをうまく利用するには

  • できるだけ小さなコンポーネントにする
    👉 Next.jsでは、1つのページでSC, CCの両方を混在させながら利用できる

SC:ナビゲーションバー, サイドバー, メインエリア!

CC:検索フォーム, ボタン

アライ リョータアライ リョータ

SC・CCの挙動を確かめてみる

サーバーコンポーネントにconsole.logを記述してみると
sc/page.tsx
const ServerComponent = () => {
  console.log('Server Component')
  return (
    <div>
      ServerComponent
    </div>
  )
}

export default ServerComponent

開発者ツール内ではなく、ターミナルに結果が出力されている
-> つまり、コンソールログを出力するJSはブラウザ(クライアント)ではなくサーバーで実行されていることがわかる

ターミナル
Server Component
 GET /sc 200 in 220ms
クライアントコンポーネントにconsole.logを記述してみると
cc/page.tsx
'use client'

const ClientComponent = () => {
  console.log('Client Component')
  return (
    <div>
      ClientComponent
    </div>
  )
}

export default ClientComponent

開発者ツール内に結果が出力されている
-> つまり、コンソールログを出力するJSはブラウザ(クライアント)で実行されていることがわかる

開発者ツール
Client Component

その他、前述の通り、HooksやイベントをSC内で使おうとするとエラーが発生する

アライ リョータアライ リョータ

ルートハンドラ

https://ja.next-community-docs.dev/docs/app-router/building-your-application/routing/route-handlers

  • 規約で定められてはいないが、プロジェクトの可読性を高めるためにapiディレクトリを置くことを推奨
GETリクエストに対してタスクのリストをJSON形式で返すエンドポイントを実装
app/api/tasks/route.ts
import { NextResponse } from "next/server";

export interface Task {
  id: number;
  name: string;
}

const tasks: Task[] = [
  {id: 1, name: 'プログラミング'},
  {id: 2, name: 'サウナ'},
];

export const GET = async () => { // 非同期関数で、HTTP GETリクエストを処理
  return NextResponse.json({ tasks }, { status: 200 }) // JSON形式のレスポンスを生成
}

export const dynamic = 'force-dynamic'; // Next.jsの動的レンダリング設定

APIからタスクデータを取得し、そのデータを表示
app/task/page.tsx
import { Task } from '@/app/api/tasks/route'; // Taskインターフェースをimport

const getTasks = async () => {
  // fetchを使用して、指定されたURLにGETリクエストを送信
  const response = await fetch("http://localhost:3000/api/tasks", {
    method: "GET", // デフォルトでGETのため、省略可能
  });
  return await response.json(); // レスポンスをJSON形式に変換し、そのデータを返却
}

const TaskPage = async () => {
  // getTasks関数を呼び出し、取得したデータをtasksで配列として扱う
  const tasks = (await getTasks()).tasks as Task[];
  return (
    <div>
      <div>TaskPage</div>
      <div>{tasks.map((task) => (
        <div key={task.id}>{task.name}</div>
      ))}</div>
    </div>
  );
}

export default TaskPage

fetchのキャッシュ機能

データ取得時にfetchにより取得されたキャッシュデータを利用
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  logging: {
    fetches: {
      fullUrl: true
    }
  }
};

export default nextConfig;
ターミナル
 GET /task 200 in 2211ms
 │ GET http://localhost:3000/api/tasks 200 in 121ms (cache hit)
 ✓ Compiled /_not-found in 215ms (478 modules)
 GET /service_worker.js 404 in 345ms
 GET /task 200 in 242ms
 │ GET http://localhost:3000/api/tasks 200 in 2ms (cache hit)
 GET /service_worker.js 404 in 34ms
 GET /task 200 in 31ms
 │ GET http://localhost:3000/api/tasks 200 in 1ms (cache hit)
 GET /service_worker.js 404 in 26ms
 GET /task 200 in 32ms
 │ GET http://localhost:3000/api/tasks 200 in 2ms (cache hit)
 GET /service_worker.js 404 in 19ms
 GET /task 200 in 24ms
 │ GET http://localhost:3000/api/tasks 200 in 3ms (cache hit)
 GET /service_worker.js 404 in 26ms
データの更新頻度が高いアプリ等でキャッシュを利用したくない場合
fetch関数の第2引数にno-storeを指定
import { Task } from '@/app/api/tasks/route';

const getTasks = async () => {
  const response = await fetch("http://localhost:3000/api/tasks", {
    method: "GET",
    cache: 'no-store'
  });
  return await response.json();
}

const TaskPage = async () => {
  const tasks = (await getTasks()).tasks as Task[];
  return (
    <div>
      <div>TaskPage</div>
      <div>{tasks.map((task) => (
        <div key={task.id}>{task.name}</div>
      ))}</div>
    </div>
  );
}

export default TaskPage

ターミナル
GET /task 200 in 82ms
 │ GET http://localhost:3000/api/tasks 200 in 33ms (cache skip)
 │ │ Cache skipped reason: (cache: no-store)
 GET /api/tasks 200 in 8ms
 GET /task 200 in 43ms
 │ GET http://localhost:3000/api/tasks 200 in 16ms (cache skip)
 │ │ Cache skipped reason: (cache: no-store)
 GET /service_worker.js 404 in 40ms
アライ リョータアライ リョータ

Server Actions

例:フォーム送信によるデータの変更処理

従来

クライアント側のAPI呼び出しのコードを実行 -> サーバーで処理を実行

Server Actions
<form action={createTask}>
...
</form>

クライアントからPLを介さずにサーバー側の処理を実行

Server Actionsの例
  • Server Actionsを定義
actions/sampleActions
'use server'; // Server Actionsを定義。このファイルがサーバーサイドで実行されることを明示

// taskId と formData を引数として受け取る非同期関数(createTask)
export const createTask = async (taskId: number, FormData: FormData) => {
  // DBにタスクを作成
  console.log('タスクを作成しました');
  console.log(FormData.get("name")); // フォームデータから name フィールドの値を取得
  console.log(taskId);
}
  • フォームを通じてServer Actionsを呼び出す
app/sa/page.tsx
import { createTask } from "@/actions/sampleActions" // Server Actionをimport

const ServerActionPage = () => {
  const taskId = 1;
  // createTask 関数をバインドして、taskId を固定値として渡す
  const createTaskWithTaskID = createTask.bind(null, taskId)
  return (
    <div>
      // action 属性に createTaskWithTaskID を指定。フォームが送信されると、この関数がサーバーサイドで実行される
      <form action={createTaskWithTaskID}>
        <input type="text" id="name" name="name" className="bg-gray-200" />
        <button type="submit" className="bg-gray-400" ml-2 px-2>
          送信
        </button>
      </form>
    </div>
  );
}

export default ServerActionPage

  • ターミナルにサーバーで実行された結果が出力されていることがわかる
 GET /sa 200 in 102ms
 GET /service_worker.js 404 in 85ms
 ✓ Compiled in 424ms (732 modules)
タスクを作成しました
test // name
1 // taskId
アライ リョータアライ リョータ

Next.jsを使用したタスク管理アプリケーション

構成要素
  • メインページ
    • タスク作成ボタン -> タスク作成画面
  • 完了タスクページ
  • 期限切れタスクページ
レイアウト
  • サイドメニュー
  • タスク表示エリア
    • タスク(カード形式)
アライ リョータアライ リョータ

プロジェクトのセットアップ

  • プロジェクト名:next-todo-app
npx create-next-app@latest

エイリアスのみNoで指定

globals.css
  • tailwindで必要な記述を残し、全て削除
globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
app/page.tsx
  • return内は全て削除
app/page.tsx
 export default function Home() {
 return (
   <div>Hello, Next.js!</div>
 );
}

npm run devを実行し、ブラウザで「hello, Next.js!」が表示されていることを確認

アライ リョータアライ リョータ

共通レイアウト(サイドメニュー・メイン画面)の作成準備

  • 今後のプロジェクト管理を踏まえ、ルートとなるpage.tsxを(main)配下に移動
  • (main)配下にlayout.tsxを作成
(main)/layout.tsx
(main)/layout.tsx
import React from "react";

const MainLayout = ({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) => {
  return (
  <div className="flex h-screen">
    <div className="bg-indigo-300">サイドメニュー</div>
    <main className="bg-red-300 flex-1 overflow-auto">{children}</main>
  </div>
  )
};

export default MainLayout;

アライ リョータアライ リョータ

サイドメニューの実装

  • src/componentsディレクトリで、サイドメニューに関連するコンポーネントを管理
    • SideMenu/SideMenu.tsx:サイドメニュー全体
    • NavList/Navlist.tsx:サイドメニュー内のリンク
    • NavItem:各リンク
大まかな実装の流れ
  • Sidemenu.tsxでNavListコンポーネントをimportして配置
  • NavListを仮デザイン
    • react-iconsをインストールして、使用するアイコンもインストール
  • NavItemで各リンクのレイアウトを作成
  • 各コンポーネントを使用して、サイドメニューのデザインを完成させる
SideMenu
SideMenu/SideMenu.tsx
import NavList from "./NavList/NavList"

const SideMenu = () => {
  return (
    <div className="w-56 pt-8 bg-sky-600 text-white">
      <div>
        <h1 className="px-4 text-2xl font-bold">Next Todo App</h1>
        <NavList />
      </div>
    </div>
  )
}

export default SideMenu

NavList
SideMenu/NavList/NavList.tsx
import { ReactNode } from "react";
import { FaRegCheckCircle, FaTasks } from "react-icons/fa";
import { FaRegClock } from "react-icons/fa6";
import NavItem from "./NavItem/NavItem";

interface NavItemType {
  id: number;
  label: string;
  link: string;
  icon: ReactNode;
}

const NavList = () => {
  const navList: NavItemType[] = [
    {
      id: 1,
      label: "すべてのToDo",
      link: "/",
      icon: <FaTasks className="size-5" />,
    },
    {
      id: 2,
      label: "完了したToDo",
      link: "/completed",
      icon: <FaRegCheckCircle className="size-5" />,
    },
    {
      id: 3,
      label: "期限切れのToDo",
      link: "/expired",
      icon: <FaRegClock className="size-5" />,
    },
  ];
  return (
    <div className="mt-24">
      {navList.map((item) => (
        <NavItem key={item.id} label={item.label} link={item.link} icon={item.icon} />
      ))}
    </div>
  );
};

export default NavList;
NavItem
SideMenu/NavList/NavItem/NavItem.tsx
'use client' // Hooksを使用するため

import Link from "next/link";
import { usePathname } from "next/navigation";
import { FC, ReactNode } from "react";

interface NavItemProps {
  label: string;
  link: string;
  icon: ReactNode;
}

const NavItem: FC<NavItemProps> = (props) => {
  const { label, link, icon } = props;
  const pathname = usePathname();
  return (
    <Link
      href={link}
      className={`flex p-4 items-center w-full hover:bg-sky-500 font-medium
      ${pathname == link ? 'bg-sky-400 border-r-4 border-r-amber-400' : ''}
      `}>
      <div className="pr-2">{icon}</div>
      <div>{label}</div>
    </Link>
  );
};

export default NavItem;

アライ リョータアライ リョータ

メインページの実装

  • メインページの構成
    • ヘッダー(見出し・ToDo作成リンクボタン)
    • タスクカード表示エリア
メインページのデザイン
(main)/page.tsx
import Link from "next/link";
import { MdAddTask } from "react-icons/md";

export default function MainPage() {
  return (
    <div className="text-gray-800 p-8 h-full overflow-y-auto pb-24">
      <header className="flex justify-between items-center">
        <h1 className="text-2xl font-bold flex items-center">すべてのTodo</h1>
        <Link
          href="/new"
          className="flex items-center gap-1 font-semibold border outline-2 border-sky-700 px-4 py-2 rounded-full shadow-md text-sky-700 hover:text-sky-500 hover:border-sky-500"
        >
          <MdAddTask className="size-5" />
          <div>ToDoを作成</div>
        </Link>
      </header>
      <div className="mt-8 flex flex-wrap gap-4">タスクカード</div>
    </div>
  );
}

アライ リョータアライ リョータ

タスクカードの実装

  • TaskCardコンポーネントを作成
    • タスクカードには、最終的に親コンポーネントから渡された情報を利用するが
      この段階では、ダミーとして仮実装
メインページ
(main)/page.tsx
import TaskCard from "@/components/TaskCard/TaskCard";
import Link from "next/link";
import { MdAddTask } from "react-icons/md";

export default function MainPage() {
  return (
    <div className="text-gray-800 p-8 h-full overflow-y-auto pb-24">
      <header className="flex justify-between items-center">
        <h1 className="text-2xl font-bold flex items-center">すべてのTodo</h1>
        <Link
          href="/new"
          className="flex items-center gap-1 font-semibold border outline-2 border-sky-700 px-4 py-2 rounded-full shadow-md text-sky-700 hover:text-sky-500 hover:border-sky-500"
        >
          <MdAddTask className="size-5" />
          <div>ToDoを作成</div>
        </Link>
      </header>
      <div className="mt-8 flex flex-wrap gap-4">
        <TaskCard />
      </div>
    </div>
  );
}
タスクカード
components/TaskCard/TaskCard.tsx
import TaskDeleteButton from "./TaskDeleteButton/TaskDeleteButton";
import TaskEditButton from "./TaskEditButton/TaskEditButton"

const TaskCard = () => {
  return (
    <div className="w-64 h-52 p-4 bg-white rounded-md shadow-md flex flex-col justify-between">
      <header>
        <h1 className="text-lg font-semibold">タイトル</h1>
        <div className="mt-1 text-sm line-clamp-3">ToDoの説明</div>
      </header>
      <div>
        <div className="text-sm">2024-12-31</div>
        <div className="flex justify-between items-center">
          <div className={`mt-1 text-sm px-2 py-1 w-24 text-center rounded-full shadow-sm ${true ? 'text-sky-400 border outline-2 border-sky-400' : 'text-rose-400 border outline-2 border-rose-400'}`}>{true ? "完了" : "未完了"}</div>
          <div className="flex gap-4">
            <TaskEditButton id='1' />
            <TaskDeleteButton id='1' />
          </div>
        </div>
      </div>
    </div>
  );
}

export default TaskCard
タスク編集ボタン
components/TaskCard/TaskEditButton/TaskEditButton.tsx
import Link from "next/link";
import { FC } from "react";
import { FaPen } from "react-icons/fa6";
interface TaskEditButtonProps {
  id: string;
}

const TaskEditButton: FC<TaskEditButtonProps> = (props) => {
  const { id } = props;
  return (
    <Link href={`/edit/${id}`}>
      <FaPen className="hover:text-gray-700 text-lg cursor-pointer" />
    </Link>
  );
};

export default TaskEditButton;

タスク削除ボタン
components/TaskCard/TaskDeleteButton/TaskDeleteButton.tsx
import Link from "next/link";
import { FC } from "react";
import { FaTrashCan } from "react-icons/fa6";

interface TaskDeleteButtonProps {
  id: string;
}

const TaskDeleteButton: FC<TaskDeleteButtonProps> = (props) => {
  const { id } = props;
  return (
    // Server Actionsを使用するため、formを使用
    <form action="">
      <button
        type="submit"
        className="hover:text-gray-700 text-lg cursor-pointer"
      >
        <FaTrashCan />
      </button>
    </form>
  );
};

export default TaskDeleteButton;

tailwindメモ
import React from 'react';

const ExampleComponent = () => {
  return (
    <div className="flex flex-col justify-between h-64 border p-4">
      <div className="bg-red-500 p-2">Item 1</div>
      <div className="bg-green-500 p-2">Item 2</div>
      <div className="bg-blue-500 p-2">Item 3</div>
    </div>
  );
}

export default ExampleComponent;

flex: 要素をフレックスコンテナにする(横並び)
flex-col: フレックス方向を列方向(縦)にする
justify-between: 最初と最後のアイテムをコンテナの端に配置し、残りのスペースをアイテム間に均等に配置する
👉 flex-colを指定してjustify-betweenを使用すると、縦方向で各要素が端に配置される

アライ リョータアライ リョータ

完了ToDoページ・期限切れToDoページの実装

  • /completedと/expiredでアクセスできるページを作成
  • 基本的なデザインはルートのページと同様
  • usePathname Hooksにより、サイドメニューのリンクが機能していることも確認
完了ToDoページ
(main)/completed/page.tsx
import TaskCard from "@/components/TaskCard/TaskCard";

const CompletedTaskPage = () => {
  return (
    <div className="text-gray-800 p-8 h-full overflow-y-auto pb-24">
      <header className="flex justify-between items-center">
        <h1 className="text-2xl font-bold flex items-center">完了したTodo</h1>
      </header>
      <div className="mt-8 flex flex-wrap gap-4">
        <TaskCard />
      </div>
    </div>
  );
};

export default CompletedTaskPage;

期限切れToDoページ
(main)/expired/page.tsx
import TaskCard from "@/components/TaskCard/TaskCard";

const ExpiredTaskPage = () => {
  return (
    <div className="text-gray-800 p-8 h-full overflow-y-auto pb-24">
      <header className="flex justify-between items-center">
        <h1 className="text-2xl font-bold flex items-center">期限切れのTodo</h1>
      </header>
      <div className="mt-8 flex flex-wrap gap-4">
        <TaskCard />
      </div>
    </div>
  );
};

export default ExpiredTaskPage;

アライ リョータアライ リョータ

ToDo作成ページの実装

  • /newでアクセスできるページを作成
    • タスク作成フォームはコンポーネントとして作成
ToDo作成ページ
import NewTaskForm from "@/components/NewTaskForm/NewTaskForm"

const NewTaskPage = () => {
  return (
    <div className="flex flex-col justify-center py-20">
      <h2 className="text-center text-2xl font-bold">新しいTodoを作成</h2>
      <NewTaskForm />
    </div>
  )
}

export default NewTaskPage
タスク作成フォームコンポーネント
components/NewTaskForm/NewTaskForm.tsx
const NewTaskForm = () => {
  return (
    <div className="mt-10 mx-auto w-full max-w-sm">
      <form action="">
        <div>
          {/* htmlForの値とinputのidを一致させることでlabelクリック時に対応する入力フォームがフォーカスされる */}
          <label htmlFor="title" className="block text-sm font-medium">
            タイトル
          </label>
          <input
            type="text"
            id="title"
            name="title"
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        <div className="mt-6">
          <label htmlFor="description" className="block text-sm font-medium">
            説明
          </label>
          <input
            type="text"
            id="description"
            name="description"
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        <div className="mt-6">
          <label htmlFor="dueDate" className="block text-sm font-medium">
            期限
          </label>
          <input
            // type属性にdateを指定することで日付選択になる
            type="date"
            id="dueDate"
            name="dueDate"
            min="2020-01-01"
            max="2999-12-31"
            // フィールドが必須入力とする
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        <button
          type="submit"
          className="mt-8 p-1 w-full rounded-md text-white bg-sky-700 hover:bg-sky-600 text-sm font-semibold shadow-sm"
          >
            作成
        </button>
      </form>
    </div>
  );
};

export default NewTaskForm;

アライ リョータアライ リョータ

ToDo編集ページを作成

  • 動的ルーティングを使用([id]ディレクトリ)
    👉 [id]はパラメータとして取得できる
  • 基本的なデザインはNewTaskFormコンポーネントと一緒だが、完了ステータスに更新するチェックボックスを持たせる
余談:アプリ全体の基本テキストカラーを統一する
app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Next Todo App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      // テンプレートリテラルを使用し、テキストカラーを追加
      <body className={`${inter.className} text-slate-700`}>{children}</body>
    </html>
  );
}
タスク編集ページ(動的ルーティングとなるよう[id]ディレクトリを設置)
(main)/edit/[id]/page.tsx
import EditTaskForm from "@/components/EditTaskForm/EdittTaskForm";

interface Params {
  params: { id: string }
}

const EditTaskPage = ({ params }: Params ) => {
  // const id = params.id
  return (
    <div className="flex flex-col justify-center py-20">
      <h2 className="text-center text-2xl font-bold">Todoを編集</h2>
      <EditTaskForm />
    </div>
  );
}

export default EditTaskPage
タスク編集フォーム
components/EditTaskForm/EditTaskForm.tsx
const EditTaskForm = () => {
  return (
    <div className="mt-10 mx-auto w-full max-w-sm">
      <form action="">
        <div>
          <label htmlFor="title" className="block text-sm font-medium">
            タイトル
          </label>
          <input
            type="text"
            id="title"
            name="title"
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        <div className="mt-6">
          <label htmlFor="description" className="block text-sm font-medium">
            説明
          </label>
          <input
            type="text"
            id="description"
            name="description"
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        <div className="mt-6">
          <label htmlFor="dueDate" className="block text-sm font-medium">
            期限
          </label>
          <input
            type="date"
            id="dueDate"
            name="dueDate"
            min="2020-01-01"
            max="2999-12-31"
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        {/* ToDoの完了ステータスへの更新を扱えるようにチェックボックスを追加 */}
        <div className="mt-6 flex items-center">
          <input
            type="checkbox"
            id="isCompleted"
            name="isCompleted"
            className="mr-2 w-4"
          />
          <label htmlFor="isCompleted" className="text-sm">
            ToDoを完了にする
          </label>
        </div>
        <button
          type="submit"
          className="mt-8 p-1 w-full rounded-md text-white bg-sky-700 hover:bg-sky-600 text-sm font-semibold shadow-sm"
        >
          更新
        </button>
      </form>
    </div>
  );
};

export default EditTaskForm;

アライ リョータアライ リョータ

カスタム404ページの実装

  • Next.jsでは「notofound.tsx」でカスタマイズできる404ページが作成できる
    -> デフォルトの404はプロジェクト作成時に自動的に準備されているが、not-found.tsxを作成すると、そちらが適用されるようになる
カスタム404ページ
app/not-found.tsx
import Link from "next/link";

const notFoundPage = () => {
  return (
    <div className="h-screen flex flex-col justify-center items-center bg-slate-50">
      <h1
        className="text-8xl font-bold bg-gradient-to-r from-amber-500 to-emerald-500
    bg-clip-text text-transparent"
      >
        404
      </h1>
      <p className="text-4xl font-medium">Page Not Found... 🐶</p>
      <Link
        href="/"
        className="mt-4 text-xl font-semibold text-red-400 hover:text-red-300"
      >
        TOPへ戻る
      </Link>
    </div>
  );
};

export default notFoundPage;

アライ リョータアライ リョータ

エラーページの実装

  • CCであるため、コードの先頭にuse Clientを記述する
  • 基本的なデザインは404ページをコピペ(テキストとスタイリングだけ微調整)
エラーページ
app/error.tsx
'use client';

import Link from "next/link";

const ErrorPage = () => {
  return (
    <div className="h-screen flex flex-col justify-center items-center bg-slate-50">
      <h1
        className="text-8xl font-bold bg-gradient-to-r from-pink-500 to-violet-500
    bg-clip-text text-transparent"
      >
        Error
      </h1>
      <p className="text-4xl font-medium">Unexpected error occurred... 🐱</p>
      <Link
        href="/"
        className="mt-4 text-xl font-semibold text-red-400 hover:text-red-300"
      >
        TOPへ戻る
      </Link>
    </div>
  );
}

export default ErrorPage

アライ リョータアライ リョータ

タスクモデルの作成

  • モデル: MongoDBのコレクションに対して操作を行うためのインターフェース
    • モデルを利用することで、DBの操作を容易に行えるようになる
タスクモデル
src/models/task.ts
// mongooseとDocumentをimport
import mongoose, { Document } from "mongoose";

// Taskインターフェースを定義
export interface Task {
  // タイトル
  title: string;
  // 説明
  description: string;
  // 期日
  dueDate: string;
  // 完了フラグ
  isCompleted: boolean;
}

// TaskインターフェースとmongooseのDocumentインターフェースを拡張したTaskDocumentインターフェースを定義
export interface TaskDocument extends Task, Document {
  // 作成日時
  createdAt: Date;
  // 更新日時
  updatedAt: Date;
}

// タスク用のスキーマを定義
const taskSchema = new mongoose.Schema<Document>(
  {
    // タイトルフィールドの設定
    title: {
      type: String,
      required: true, // 必須フィールド
    },
    // 説明フィールドの設定
    description: {
      type: String,
    },
    // 期日フィールドの設定
    dueDate: {
      type: String,
      required: true, // 必須フィールド
    },
    // 完了フラグフィールドの設定
    isCompleted: {
      type: Boolean,
      default: false, // デフォルト値をfalseに設定
    },
  },
  { timestamps: true }
); // スキーマオプションでtimestampsをtrueにすることで、createdAtとupdatedAtフィールドを自動生成

// Taskモデルをエクスポート。既にモデルが存在する場合はそれを使用し、存在しない場合は新しく作成
export const TaskModel =
  mongoose.models.Task || mongoose.model<TaskDocument>("Task", taskSchema);

アライ リョータアライ リョータ

タスク作成機能の実装

  • Server Actionsを使用
フォームから受け取ったデータを元に新しいタスクを作成し、作成が成功した場合にホームページにリダイレクトする関数を定義
  • Server Actions
src/actions/task.ts
// Server Actionsを使用するための宣言
"use server";

import { TaskModel } from "@/models/task";
import { connectDb } from "@/utils/database";
import { redirect } from "next/navigation";

// フォームの状態を定義するインターフェース
export interface FormState {
  // Server Actions内でエラーが発生した際にその内容を返却するための型定義
  error: string;
}

// フォームから受け取ったデータを元に新しいタスクを作成し、作成が成功した場合にホームページにリダイレクトする
export const createTask = async (state: FormState, formData: FormData) => {
  // フォームデータから新しいタスクを作成
  const newTask: Task = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
    dueDate: formData.get("dueDate") as string,
    isCompleted: false,
  };

  try {
    // データベースに接続
    await connectDb();
    // 新しいタスクをデータベースに保存
    await TaskModel.create(newTask);
  } catch (error) {
    // エラーが発生した場合、エラーメッセージをstateに設定
    state.error = "ToDoの作成に失敗しました";
    return state;
  }

  // 成功した場合、ホームページにリダイレクト
  redirect("/");
};

タスク作成フォームでのタスク作成を行えるようにする
components/NewTaskForm/NewTaskForm.tsx
"use client";

import { createTask, FormState } from "@/actions/task";
import { useFormState, useFormStatus } from "react-dom";

// NewTaskFormコンポーネントを定義
const NewTaskForm = () => {
  // フォームの初期状態を定義
  const initialState: FormState = { error: "" };
  // useFormStateフックを使用してフォームの状態とアクションを取得
  const [state, formAction] = useFormState(createTask, initialState);

  // SubmitButtonコンポーネントを定義
  const SubmitButton = () => {
    // useFormStatusフックを使用してフォームの送信状態を取得
    const { pending } = useFormStatus();
    return (
      <button
        type="submit"
        className="mt-8 p-1 w-full rounded-md text-white bg-sky-700 hover:bg-sky-600 text-sm font-semibold shadow-sm disabled:bg-gray-400"
        // フォームの送信が保留中(pending)の場合、ボタンを無効化
        disabled={pending}
      >
        作成
      </button>
    );
  };

  return (
    <div className="mt-10 mx-auto w-full max-w-sm">
      {/* フォームの送信アクションを設定 */}
      <form action={formAction}>
        <div>
          <label htmlFor="title" className="block text-sm font-medium">
            タイトル
          </label>
          <input
            type="text"
            id="title"
            name="title"
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />

        ~ 中略~

        </div>
        {/* 送信ボタンを表示 */}
        <SubmitButton />
        {/* エラーがある場合にエラーメッセージを表示 */}
        {state.error && (
          <p className="mt-2 text-rose-500 text-sm">{state.error}</p>
        )}
      </form>
    </div>
  );
};

export default NewTaskForm;
  • 環境変数DB_URIに格納していたURIのパスワードの記述箇所に<>というプレースホルダーがあり、DB接続エラーが起きていたので修正。原因特定しやすくするためのエラーハンドリング大事。
  • ToDo作成成功後、MongoDBのCluster0 → Browse collectionsを確認すると
    next_todo_app(DB)にtasksコレクションが作成され、データが追加されていることが確認できる

アライ リョータアライ リョータ

タスク一覧取得APIの実装

  • 作成されたタスク一覧をDBから取得するAPIを実装
タスク一覧を取得すrためのルートハンドラ
app/api/tasks/route.ts
// 必要なモジュールやモデルをインポート
import { TaskDocument, TaskModel } from "@/models/task";
import { connectDb } from "@/utils/database";
import { NextResponse } from "next/server";

// GETリクエストのハンドラーを定義
export const GET = async () => {
  try {
    // データベースに接続
    await connectDb();
    // 全てのタスクをデータベースから取得
    const allTasks: TaskDocument[] = await TaskModel.find(); // findはmongoosの全検索メソッド
    // 取得したタスクをJSON形式で返す
    return NextResponse.json({ message: 'ToDo取得成功', tasks: allTasks });
  } catch (error) {
    // エラーが発生した場合、エラーメッセージをログに出力し、500ステータスでJSONレスポンスを返す
    console.log(error);
    return NextResponse.json({ message: 'ToDo取得失敗' }, { status: 500 });
  }
};

// このエンドポイントを動的にする設定
// エンドポイントがリクエストごとに最新のデータを取得し、キャッシュされないようにする
export const dynamic = 'force-dynamic';

アライ リョータアライ リョータ

メインページでタスク一覧を取得

メインページにアクセスしてAPIを利用してDBからデータを取得できているか確認
app/(main)/page.tsx
import TaskCard from "@/components/TaskCard/TaskCard";
import { TaskDocument } from "@/models/task";
import Link from "next/link";
import { MdAddTask } from "react-icons/md";

// すべてのタスクを取得する非同期関数を定義
const getAllTasks = async (): Promise<TaskDocument[]> => {
  // 環境変数からAPIのURLを取得し、データをフェッチする
  const response = await fetch(`${process.env.API_URL}/tasks`, {
    cache: 'no-store', // キャッシュを使用せず、常に最新のデータを取得する設定
  });

  // レスポンスのステータスが200(成功)でない場合、エラーをスローする
  if (response.status != 200) {
    throw new Error();
  }

  // レスポンスのデータをJSON形式でパースし、タスクデータを返す
  const data = await response.json();
  return data.tasks as TaskDocument[];
}

// メインページのコンポーネントを非同期関数に修正
export default async function MainPage() {
  // getAllTasks関数を呼び出し、すべてのタスクを取得する
  const allTasks = await getAllTasks();
  console.log(allTasks); // 取得したタスクをコンソールに出力して確認

  return (
    <div className="p-8 h-full overflow-y-auto pb-24">
      <header className="flex justify-between items-center">
        <h1 className="text-2xl font-bold flex items-center">すべてのTodo</h1>
        <Link
          href="/new"
          className="flex items-center gap-1 font-semibold border outline-2 border-sky-700 px-4 py-2 rounded-full shadow-md text-sky-700 hover:text-sky-500 hover:border-sky-500"
        >
          <MdAddTask className="size-5" />
          <div>ToDoを作成</div>
        </Link>
      </header>
      <div className="mt-8 flex flex-wrap gap-4">
        {/* TaskCardコンポーネントを表示。ここに取得したタスクデータを渡すと良い */}
        <TaskCard />
      </div>
    </div>
  );
}

-(SCなので) ターミナルでコンソールの出力結果が表示されていればOK

ターミナル
 GET /api/tasks 200 in 1903ms
[
  {
    _id: '6677d1992cb3960d7569a858',
    title: 'Udemy',
    description: '',
    dueDate: '2024-06-23',
    isCompleted: false,
    createdAt: '2024-06-23T07:41:13.562Z',
    updatedAt: '2024-06-23T07:41:13.562Z',
    __v: 0
  }
]
  • 確認後、console.logは削除
TaskCardコンポーネントにDBから取得したデータをpropsとして渡してmapメソッドでレンダリング
app/(main)/page.tsx
import TaskCard from "@/components/TaskCard/TaskCard";
import { TaskDocument } from "@/models/task";
import Link from "next/link";
import { MdAddTask } from "react-icons/md";

const getAllTasks = async (): Promise<TaskDocument[]> => {
  const response = await fetch(`${process.env.API_URL}/tasks`, {
    cache: "no-store",
  });

  if (response.status != 200) {
    throw new Error();
  }

  const data = await response.json();
  return data.tasks as TaskDocument[];
};

export default async function MainPage() {
  const allTasks = await getAllTasks();
  console.log(allTasks);
  return (
    <div className="p-8 h-full overflow-y-auto pb-24">
      <header className="flex justify-between items-center">
        <h1 className="text-2xl font-bold flex items-center">すべてのTodo</h1>
        <Link
          href="/new"
          className="flex items-center gap-1 font-semibold border outline-2 border-sky-700 px-4 py-2 rounded-full shadow-md text-sky-700 hover:text-sky-500 hover:border-sky-500"
        >
          <MdAddTask className="size-5" />
          <div>ToDoを作成</div>
        </Link>
      </header>
      <div className="mt-8 flex flex-wrap gap-4">
        // 取得したデータをタスクカードにpropsで渡してmapメソッドでレンダリング
        {allTasks.map((task) => (
          <TaskCard key={task._id} task={task} /> // _idはmongoDBで指導生成されるid
        ))}
      </div>
    </div>
  );
}

  • taskCardコンポーネントが、親コンポーネントからタスクデータを受け取れるように修正
親コンポーネントから渡されたpropsを受け取って動的に表示するように修正
components/TaskCard/TaskCard.tsx
import { TaskDocument } from "@/models/task";
import TaskDeleteButton from "./TaskDeleteButton/TaskDeleteButton";
import TaskEditButton from "./TaskEditButton/TaskEditButton";

interface TaskCardProps {
  task: TaskDocument;
}

const TaskCard: React.FC<TaskCardProps> = (props) => {
  const { task } = props;
  return (
    <div className="w-64 h-52 p-4 bg-white rounded-md shadow-md flex flex-col justify-between">
      <header>
        <h1 className="text-lg font-semibold">{task.title}</h1>
        <div className="mt-1 text-sm line-clamp-3">{task.description}</div>
      </header>
      <div>
        <div className="text-sm">{task.dueDate}</div>
        <div className="flex justify-between items-center">
          <div
            className={`mt-1 text-sm px-2 py-1 w-24 text-center rounded-full shadow-sm ${task.isCompleted ? "text-sky-400 border outline-2 border-sky-400" : "text-rose-400 border outline-2 border-rose-400"}`}
          >
            {task.isCompleted ? "完了" : "未完了"}
          </div>
          <div className="flex gap-4">
            <TaskEditButton id={task._id} />
            <TaskDeleteButton id={task._id} />
          </div>
        </div>
      </div>
    </div>
  );
};

export default TaskCard;
アライ リョータアライ リョータ

IDによるタスク取得APIの実装

  • IDを用いて特定のToDoを取得するルートハンドラを作成
    • 取得したデータは、編集フォームの初期状態として使用
      👉ルートハンドラの定義にも、page同様に[id]ディレクトリを介したダイナミックルーティングが使用できる
IDによるデータ取得のルートハンドラ
// 必要なモジュールやモデルをインポート
import { TaskModel } from "@/models/task";
import { connectDb } from "@/utils/database";
import { NextRequest, NextResponse } from "next/server";

// GETリクエストのハンドラーを定義
export const GET = async (
  _: NextRequest, // リクエストオブジェクトを受け取るが、ここでは使用しない
  { params }: { params: { id: string } } // URLパラメータからIDを取得
) => {
  try {
    // データベースに接続
    await connectDb();
    // 指定されたIDのタスクをデータベースから取得
    const task = await TaskModel.findById(params.id);

    // タスクが存在しない場合、404ステータスでエラーメッセージを返す
    if (!task) {
      return NextResponse.json(
        { message: "ToDoが存在しません" },
        { status: 404 }
      );
    }

    // タスクが存在する場合、200ステータスでタスクを返す
    return NextResponse.json({ message: "ToDo取得成功", task });
  } catch (error) {
    // エラーが発生した場合、エラーメッセージをコンソールに出力し、500ステータスでエラーメッセージを返す
    console.log(error);
    return NextResponse.json({ message: "ToDo取得失敗" }, { status: 500 });
  }
};

// このエンドポイントを動的にする設定
export const dynamic = "force-dynamic";
APIを利用してDBからIDを基にデータを取得できているか確認
app/(main)/edit/[id]/page.tsx
import EditTaskForm from "@/components/EditTaskForm/EdittTaskForm";
import { TaskDocument } from "@/models/task";

// Params インターフェースを定義
interface Params {
  params: { id: string }
}

// 特定のIDに基づいてタスクを取得する非同期関数を定義
const getTask = async (id: string): Promise<TaskDocument> => {
  // APIから特定のタスクをフェッチする
  const response = await fetch(`${process.env.API_URL}/tasks/${id}`, {
    cache: 'no-store', // キャッシュを使用せず、常に最新のデータを取得する設定
  });

  // レスポンスデータをJSON形式でパースし、タスクを返す
  const data = await response.json();
  return data.task as TaskDocument;
}

// EditTaskPageコンポーネントを定義
const EditTaskPage = async ({ params }: Params ) => {
  const id = params.id; // URLパラメータからIDを取得
  const task = await getTask(id); // getTask関数を呼び出し、タスクを取得
  console.log(task); // 取得したタスクをコンソールに出力して確認

  return (
    <div className="flex flex-col justify-center py-20">
      <h2 className="text-center text-2xl font-bold">Todoを編集</h2>
      <EditTaskForm />
    </div>
  );
}

export default EditTaskPage;
(SCのため)ターミナルにconsole.logの結果が出力されていればOK
ターミナル
DBに接続しました
 GET /api/tasks/6677e8482cb3960d7569a88c 200 in 758ms
{
  _id: '6677e8482cb3960d7569a88c',
  title: 'Web Developer Bootcamp',
  description: '毎日受講する',
  dueDate: '2024-08-31',
  isCompleted: false,
  createdAt: '2024-06-23T09:18:00.841Z',
  updatedAt: '2024-06-23T09:18:00.841Z',
  __v: 0
}

  • console.logは削除
アライ リョータアライ リョータ

タスク編集機能の実装

タスク編集フォームのコンポーネントにidに基づいたタスクデータを定義したtaskをpropsとして渡す
app/(main)/edit/[id]/page.tsx
const getTask = async (id: string): Promise<TaskDocument> => {
  const response = await fetch(`${process.env.API_URL}/tasks/${id}`, {
    cache: 'no-store',
  });

  const data = await response.json();
  return data.task as TaskDocument;
}

const EditTaskPage = async ({ params }: Params ) => {
  const id = params.id
  const task = await getTask(id);

  return (
    <div className="flex flex-col justify-center py-20">
      <h2 className="text-center text-2xl font-bold">Todoを編集</h2>
      // 
      <EditTaskForm task={task} />
    </div>
  );
}
propsで受け取ったtask(idに紐づいたタスクデータ)をuseStateで状態管理できるようにする
components/EditTaskForm/EditTaskForm.tsx
'use client'  // Hooksを使うため、use clientディレクティブを宣言

import { TaskDocument } from "@/models/task";
import { useState } from "react";

interface EditTaskFormProps {
  task: TaskDocument;
}

// 親コンポーネントから渡されたpropsを受け取り、各フォームの値をuseStateで扱えるようにする
const EditTaskForm: React.FC<EditTaskFormProps> = ({ task }) => {
  const [title, setTitle] = useState(task.title);
  const [description, setDescription] = useState(task.description);
  const [dueDate, setDueDate] = useState(task.dueDate);
  const [isCompleted, setIsCompleted] = useState(task.isCompleted);

  const onChangeTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
    setTitle(e.target.value)
  };

  const onChangeDescription = (e: React.ChangeEvent<HTMLInputElement>) => {
    setDescription(e.target.value);
  };

  const onChangeDueDate = (e: React.ChangeEvent<HTMLInputElement>) => {
    setDueDate(e.target.value);
  };

  const onChangeIsCompleted = (e: React.ChangeEvent<HTMLInputElement>) => {
    // isCompletedはチェックボックスなのでvalueではなくchecked
    setIsCompleted(e.target.checked);
  };

  return (
    <div className="mt-10 mx-auto w-full max-w-sm">
      <form action="">
        <div>
          <label htmlFor="title" className="block text-sm font-medium">
            タイトル
          </label>
          <input
            type="text"
            id="title"
            name="title"
            value={title}
            onChange={onChangeTitle}
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        <div className="mt-6">
          <label htmlFor="description" className="block text-sm font-medium">
            説明
          </label>
          <input
            type="text"
            id="description"
            name="description"
            value={description}
            onChange={onChangeDescription}
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        <div className="mt-6">
          <label htmlFor="dueDate" className="block text-sm font-medium">
            期限
          </label>
          <input
            type="date"
            id="dueDate"
            name="dueDate"
            value={dueDate}
            onChange={onChangeDueDate}
            min="2020-01-01"
            max="2999-12-31"
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        {/* ToDoの完了ステータスへの更新を扱えるようにチェックボックスを追加 */}
        <div className="mt-6 flex items-center">
          <input
            type="checkbox"
            id="isCompleted"
            name="isCompleted"
            checked={isCompleted}
            onChange={onChangeIsCompleted}
            className="mr-2 w-4"
          />
          <label htmlFor="isCompleted" className="text-sm">
            ToDoを完了にする
          </label>
        </div>
        <button
          type="submit"
          className="mt-8 p-1 w-full rounded-md text-white bg-sky-700 hover:bg-sky-600 text-sm font-semibold shadow-sm"
        >
          更新
        </button>
      </form>
    </div>
  );
};

export default EditTaskForm;

👉 上記修正後、タスクの編集ページにアクセスした時に、フォームの値が表示されていればOK

タスク更新のServer Actiosnを定義
src/actions/task.ts
// タスクを更新する非同期関数
export const updateTask = async (id: string, state: FormState, formData: FormData) => {
  // フォームデータから更新するタスクを作成
  const updatedTask = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
    dueDate: formData.get("dueDate") as string,
    isCompleted: Boolean(formData.get('isCompleted')),
  };

  try {
    // データベースに接続
    await connectDb();
    // 特定のIDを持つタスクを更新
    await TaskModel.updateOne({ _id: id }, updatedTask);
  } catch (error) {
    // エラーが発生した場合、エラーメッセージをstateに設定
    state.error = "ToDoの更新に失敗しました";
    return state;
  }

  // 更新が成功した場合、ホームページにリダイレクト
  redirect("/");
};
更新処理を実装
components/EditTaskForm/EditTaskForm.tsx
"use client";

import { FormState, updateTask } from "@/actions/task"; // updateTask関数をインポート
import { TaskDocument } from "@/models/task";
import { useState } from "react";
import { useFormState, useFormStatus } from "react-dom"; // useFormStateとuseFormStatusをインポート

interface EditTaskFormProps {
  task: TaskDocument;
}

const EditTaskForm: React.FC<EditTaskFormProps> = ({ task }) => {
  const [title, setTitle] = useState(task.title);
  const [description, setDescription] = useState(task.description);
  const [dueDate, setDueDate] = useState(task.dueDate);
  const [isCompleted, setIsCompleted] = useState(task.isCompleted);

  // updateTask関数をタスクIDでバインド
  const updateTaskWithId = updateTask.bind(null, task._id);
  const initialState: FormState = { error: "" };
  // useFormStateフックを使ってフォーム状態とアクションを管理
  const [state, formAction] = useFormState(updateTaskWithId, initialState);

  const SubmitButton = () => {
    const { pending } = useFormStatus(); // フォームの送信状態を取得
    return (
      <button
        type="submit"
        disabled={pending} // 送信中はボタンを無効化
        className="mt-8 p-1 w-full rounded-md text-white bg-sky-700 hover:bg-sky-600 text-sm font-semibold shadow-sm disabled:bg-gray-400"
      >
        更新
      </button>
    );
  };

  const onChangeTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
    setTitle(e.target.value);
  };

  const onChangeDescription = (e: React.ChangeEvent<HTMLInputElement>) => {
    setDescription(e.target.value);
  };

  const onChangeDueDate = (e: React.ChangeEvent<HTMLInputElement>) => {
    setDueDate(e.target.value);
  };

  const onChangeIsCompleted = (e: React.ChangeEvent<HTMLInputElement>) => {
    setIsCompleted(e.target.checked);
  };

  return (
    <div className="mt-10 mx-auto w-full max-w-sm">
      <form action={formAction}> {/* フォームのアクションにformActionを設定 */}
        <div>
          <label htmlFor="title" className="block text-sm font-medium">
            タイトル
          </label>
          <input
            type="text"
            id="title"
            name="title"
            value={title}
            onChange={onChangeTitle}
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        <div className="mt-6">
          <label htmlFor="description" className="block text-sm font-medium">
            説明
          </label>
          <input
            type="text"
            id="description"
            name="description"
            value={description}
            onChange={onChangeDescription}
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        <div className="mt-6">
          <label htmlFor="dueDate" className="block text-sm font-medium">
            期限
          </label>
          <input
            type="date"
            id="dueDate"
            name="dueDate"
            value={dueDate}
            onChange={onChangeDueDate}
            min="2020-01-01"
            max="2999-12-31"
            required
            className="block mt-2 py-1.5 w-full rounded-md border-0 shadow-sm ring-1 ring-inset ring-gray-300"
          />
        </div>
        {/* ToDoの完了ステータスへの更新を扱えるようにチェックボックスを追加 */}
        <div className="mt-6 flex items-center">
          <input
            type="checkbox"
            id="isCompleted"
            name="isCompleted"
            checked={isCompleted}
            onChange={onChangeIsCompleted}
            className="mr-2 w-4"
          />
          <label htmlFor="isCompleted" className="text-sm">
            ToDoを完了にする
          </label>
        </div>
        <SubmitButton /> {/* SubmitButtonコンポーネントを使用 */}
        {state.error !== '' && (
          <p className="mt-2 text-rose-500 text-sm">
            {state.error} {/* エラーメッセージを表示 */}
          </p>
        )}
      </form>
    </div>
  );
};

export default EditTaskForm;
アライ リョータアライ リョータ

タスク削除機能の実装

  • 基本的な実装手順はタスク編集機能と大体同じ
タスク削除のServer Actionsを定義
src/actions/task.ts
export const deleteTask = async (id: string, state: FormState) => {
  try {
    await connectDb();
    await TaskModel.deleteOne({ _id: id });
  } catch (error) {
    state.error = "ToDoの削除に失敗しました";
    return state;
  }

  redirect("/");
};
削除ボタンに削除機能を実装
  • 削除機能は、タスク作成や編集のようなフォーム画面を持たないため、useEffectを使用してエラー発生時はポップアップで表示するように実装
components/TaskCard/TaskDeleteButton/TaskDeleteButton.tsx
'use client';

import { deleteTask, FormState } from "@/actions/task";
import { useEffect } from "react";
import { useFormState, useFormStatus } from "react-dom";
import { FaTrashCan } from "react-icons/fa6";

interface TaskDeleteButtonProps {
  id: string;
}

const TaskDeleteButton: React.FC<TaskDeleteButtonProps> = (props) => {
  const { id } = props;
  // deleteTask関数をタスクIDでバインド
  const deleteTaskWithId = deleteTask.bind(null, id);
  const initialState: FormState = { error: '' };
  // useFormStateフックを使ってフォーム状態とアクションを管理
  const [state, formAction] = useFormState(deleteTaskWithId, initialState);

  // エラーメッセージが存在する場合にアラートを表示
  useEffect(() => {
    if (state && state.error !== '') {
      alert(state.error);
    }
  }, [state]);

  const SubmitButton = () => {
    const { pending } = useFormStatus(); // フォームの送信状態を取得
    return (
      <button
        type="submit"
        disabled={pending} // 送信中はボタンを無効化
        className="hover:text-gray-700 text-lg cursor-pointer disabled:bg-gray-400"
      >
        <FaTrashCan />
      </button>
    );
  };

  return (
    <form action={formAction}> {/* フォームのアクションにformActionを設定 */}
      <SubmitButton />
    </form>
  );
};

export default TaskDeleteButton;
アライ リョータアライ リョータ

完了タスクページの実装

  • ルートハンドラで、完了状態がtrueのものを取得条件とする
isCompletedがtrueのデータ取得のルートハンドラ
app/api/tasks/completed/route.ts
import { TaskDocument, TaskModel } from "@/models/task";
import { connectDb } from "@/utils/database";
import { NextResponse } from "next/server";

export const GET = async () => {
  try {
    await connectDb();
    const completedTasks: TaskDocument[] = await TaskModel.find({
      isCompleted: true, // findの検索条件にisCompletedがtrueであることを指定
    });
    return NextResponse.json({ message: "ToDo取得成功", tasks: completedTasks });
  } catch (error) {
    console.log(error);
    return NextResponse.json({ message: "ToDo取得失敗" }, { status: 500 });
  }
};

export const dynamic = "force-dynamic";

APIを利用してisCompletedがtrueのタスクを表示
app/(main)/completed/page.tsx
import TaskCard from "@/components/TaskCard/TaskCard";
import { TaskDocument } from "@/models/task";

const getCompletedTasks = async (): Promise<TaskDocument[]> => {
  // 完了タスクのルートハンドラのURLを指定
  const response = await fetch(`${process.env.API_URL}/tasks/completed`, {
    cache: 'no-store',
  });

  if (response.status !== 200) {
    throw new Error();
  }

  const data = await response.json();
  return data.tasks as TaskDocument[];
}

const CompletedTaskPage = async () => {
  const completedTasks = await getCompletedTasks();
  return (
    <div className="text-gray-800 p-8 h-full overflow-y-auto pb-24">
      <header className="flex justify-between items-center">
        <h1 className="text-2xl font-bold flex items-center">完了したTodo</h1>
      </header>
      <div className="mt-8 flex flex-wrap gap-4">
        {completedTasks.map((task) => <TaskCard key={task._id} task={task} />)}
      </div>
    </div>
  );
};

export default CompletedTaskPage;
アライ リョータアライ リョータ

期限切れタスクページの実装

  • 基本的な実装の流れは完了タスクとほぼ同じ
  • 「期限切れ」の条件:isCompletedがfalse・dueDateが現在の日付以前
期限切れタスクのデータ取得のルートハンドラ
app/api/tasks/expired/route.ts
import { TaskDocument, TaskModel } from "@/models/task"; // Taskモデルをインポート
import { connectDb } from "@/utils/database"; // データベース接続ユーティリティをインポート
import { NextResponse } from "next/server"; // Next.jsのレスポンスオブジェクトをインポート

// GETリクエストのハンドラーを定義
export const GET = async () => {
  // 現在の日付を日本語形式で取得し、YYYY-MM-DD形式に変換
  const currentDate = new Date().toLocaleDateString('ja-JP', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit'
  }).replace(/\//g, '-');
  
  try {
    // データベースに接続
    await connectDb();
    // 未完了かつ期限切れのタスクを検索
    const expiredTasks: TaskDocument[] = await TaskModel.find({
      isCompleted: false, // 未完了のタスク
      dueDate: { $lt: currentDate }, // 期限が現在の日付より前のタスク
    });
    // 検索結果をJSON形式で返す
    return NextResponse.json({
      message: "ToDo取得成功",
      tasks: expiredTasks,
    });
  } catch (error) {
    // エラーが発生した場合、エラーメッセージをログに出力し、500エラーレスポンスを返す
    console.log(error);
    return NextResponse.json({ message: "ToDo取得失敗" }, { status: 500 });
  }
};

// エンドポイントを動的にする設定
export const dynamic = "force-dynamic";

APIを利用して期限切れタスクを表示
app/(main)/expired/page.tsx
import TaskCard from "@/components/TaskCard/TaskCard";
import { TaskDocument } from "@/models/task";

const getExpiredTasks = async (): Promise<TaskDocument[]> => {
  const response = await fetch(`${process.env.API_URL}/tasks/expired`, {
    cache: "no-store",
  });

  if (response.status !== 200) {
    throw new Error();
  }

  const data = await response.json();
  return data.tasks as TaskDocument[];
};

const ExpiredTaskPage = async () => {
  const expiredTasks = await getExpiredTasks();
  return (
    <div className="text-gray-800 p-8 h-full overflow-y-auto pb-24">
      <header className="flex justify-between items-center">
        <h1 className="text-2xl font-bold flex items-center">期限切れのTodo</h1>
      </header>
      <div className="mt-8 flex flex-wrap gap-4">
        {expiredTasks.map((task) => <TaskCard key={task._id} task={task} />)}
      </div>
    </div>
  );
};

export default ExpiredTaskPage;

アライ リョータアライ リョータ

ローディングコンポーネントの実装

  • (main)ディレクトリ直下に、loading.tsx(拡張子は状況に合わせて)というファイルを作成すると、ローディング画面として機能する
ローディング画面を実装
  • Tailwindで、簡易的なローディングスピナーをスタイリング
app/(main)/loading.tsx
const loading = () => {
  return (
    <div
      className="h-full flex justify-center items-center"
      aria-label="読み込み中あ"
    >
      <div className="animate-spin h-10 w-10 border-4 border-sky-500 rounded-full border-t-transparent"></div>
    </div>
  );
};

export default loading;
アライ リョータアライ リョータ

Vercelへのデプロイ

https://vercel.com/haganenoubiks-projects

デプロイ時に発生したエラー
ターミナル
Failed to compile.
./src/app/(main)/completed/page.tsx:25:49
Type error: Type 'unknown' is not assignable to type 'Key | null | undefined'.
  23 |       </header>
  24 |       <div className="mt-8 flex flex-wrap gap-4">
> 25 |         {completedTasks.map((task) => <TaskCard key={task._id} task={task} />)}
     |                                                 ^
  26 |       </div>
  27 |     </div>
  28 |   );
Error: Command "npm run build" exited with 1

🤔💭 要約すると
completedTasks.map内のtask._idがunknown型として扱われているため、Reactの keyプロパティに渡すことができないことを示している

unknown型はTypeScriptにおける特別な型で、任意の値を表現できるが、そのままでは他の型に代入することができない。
unknown型の値を他の具体的な型に変換するには型アサーションや型チェックが必要。

keyプロパティはReactの各要素に一意性を与えるために使用され、文字列や数値が期待されるが、unknown型はReactのkeyプロパティに直接渡すことができない。(型安全性を確保するため)

String型に変換することで、型システムがkeyプロパティに渡す値の型を明確に認識できるようになる。