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
VSCodeのReact Component作成のスニペット
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]ディレクトリを利用することで、動的ルーティングが生成される
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: "/"
動的ルーティング
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の内容が描画される
const NotFound = () => {
return (
<div>
NotFoundPage
</div>
)
}
export default NotFound
- error.tsxを作成する
- ネストも可能なため、複数のerror.tsxが存在する場合、最も近いErrorコンポーネントが描画される
'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を記述してみると
const ServerComponent = () => {
console.log('Server Component')
return (
<div>
ServerComponent
</div>
)
}
export default ServerComponent
開発者ツール内ではなく、ターミナルに結果が出力されている
-> つまり、コンソールログを出力するJSはブラウザ(クライアント)ではなくサーバーで実行されていることがわかる
Server Component
GET /sc 200 in 220ms
クライアントコンポーネントにconsole.logを記述してみると
'use client'
const ClientComponent = () => {
console.log('Client Component')
return (
<div>
ClientComponent
</div>
)
}
export default ClientComponent
開発者ツール内に結果が出力されている
-> つまり、コンソールログを出力するJSはブラウザ(クライアント)で実行されていることがわかる
Client Component
その他、前述の通り、HooksやイベントをSC内で使おうとするとエラーが発生する
ルートハンドラ
- 規約で定められてはいないが、プロジェクトの可読性を高めるためにapiディレクトリを置くことを推奨
GETリクエストに対してタスクのリストをJSON形式で返すエンドポイントを実装
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からタスクデータを取得し、そのデータを表示
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により取得されたキャッシュデータを利用
/** @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
データの更新頻度が高いアプリ等でキャッシュを利用したくない場合
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
Loading UI
Server Actions
例:フォーム送信によるデータの変更処理
従来
クライアント側のAPI呼び出しのコードを実行 -> サーバーで処理を実行
Server Actions
<form action={createTask}>
...
</form>
クライアントからPLを介さずにサーバー側の処理を実行
Server Actionsの例
- Server Actionsを定義
'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を呼び出す
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
useFormState
useFormStatus
Next.jsを使用したタスク管理アプリケーション
構成要素
- メインページ
- タスク作成ボタン -> タスク作成画面
- 完了タスクページ
- 期限切れタスクページ
レイアウト
- サイドメニュー
- タスク表示エリア
- タスク(カード形式)
プロジェクトのセットアップ
- プロジェクト名:next-todo-app
npx create-next-app@latest
エイリアスのみNo
で指定
globals.css
- tailwindで必要な記述を残し、全て削除
@tailwind base;
@tailwind components;
@tailwind utilities;
app/page.tsx
- return内は全て削除
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
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
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
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
'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作成リンクボタン)
- タスクカード表示エリア
メインページのデザイン
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コンポーネントを作成
- タスクカードには、最終的に親コンポーネントから渡された情報を利用するが
この段階では、ダミーとして仮実装
- タスクカードには、最終的に親コンポーネントから渡された情報を利用するが
メインページ
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>
);
}
タスクカード
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
タスク編集ボタン
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;
タスク削除ボタン
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ページ
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ページ
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
タスク作成フォームコンポーネント
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コンポーネントと一緒だが、完了ステータスに更新するチェックボックスを持たせる
余談:アプリ全体の基本テキストカラーを統一する
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]ディレクトリを設置)
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
タスク編集フォーム
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ページ
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ページをコピペ(テキストとスタイリングだけ微調整)
エラーページ
'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
↓ CRUD機能の実装
Mongo DBのセットアップ
- NoSQLデータベース
- データベースにデータを保存する際に、テーブルのような構造ではなく、JSON形式でデータを保存するため、柔軟なデータベース設計が可能
※要学習
Next.jsとMongo DBの接続設定
- mongooseをインストール
タスクモデルの作成
- モデル: MongoDBのコレクションに対して操作を行うためのインターフェース
- モデルを利用することで、DBの操作を容易に行えるようになる
タスクモデル
// 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
// 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("/");
};
タスク作成フォームでのタスク作成を行えるようにする
"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ためのルートハンドラ
// 必要なモジュールやモデルをインポート
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';
メインページでタスク一覧を取得
- 作成したルートハンドラを使用して、メインページでタスク一覧を呼び出せるようにする
- エンドポイントの共通部分(http://localhost:3000/api)は環境変数API_URLとして管理
メインページにアクセスしてAPIを利用してDBからデータを取得できているか確認
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メソッドでレンダリング
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を受け取って動的に表示するように修正
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を基にデータを取得できているか確認
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として渡す
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で状態管理できるようにする
'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を定義
// タスクを更新する非同期関数
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("/");
};
更新処理を実装
"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を定義
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を使用してエラー発生時はポップアップで表示するように実装
'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のデータ取得のルートハンドラ
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のタスクを表示
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が現在の日付以前
期限切れタスクのデータ取得のルートハンドラ
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を利用して期限切れタスクを表示
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で、簡易的なローディングスピナーをスタイリング
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へのデプロイ
デプロイ時に発生したエラー
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プロパティに渡す値の型を明確に認識できるようになる。