前の記事
次の記事
フォームの入力エラーを画面内に表示する仕組みを作る
タスク新規登録
タスク名
[ ]
タスク名を入力してください。
担当者
[ 選択してください ▼ ]
[登録]
のように画面内に入力エラーを表示する。
今回の仕組み
新規登録画面
↓
createTask(formData)
↓
ASP.NET Core API
を、
新規登録画面
↓
useActionState
↓
createTask(previousState, formData)
↓
入力チェック
├─ NG → エラー情報を画面へ返す
│
└─ OK → ASP.NET Core API
↓
登録
↓
一覧へ
にする。
task-actions.tsを変更
src/app/actions/task-actions.tsに追加する。
export type TaskActionState = {
errors?: {
taskName?: string[];
operatorId?: string[];
status?: string[];
plannedDate?: string[];
};
message?: string | null;
};
createTask()を変更する。
export async function createTask(
_previousState: TaskActionState,
formData: FormData
): Promise<TaskActionState> {
const taskName =
formData.get("taskName")?.toString().trim() ?? "";
const operatorId = Number(
formData.get("operatorId")
);
const status = Number(
formData.get("status")
);
const plannedDateText =
formData.get("plannedDate")?.toString() ?? "";
// 入力エラーを格納する
const errors: TaskActionState["errors"] = {};
// タスク名チェック
if (taskName === "") {
errors.taskName = [
"タスク名を入力してください。",
];
} else if (taskName.length > 200) {
errors.taskName = [
"タスク名は200文字以内で入力してください。",
];
}
// 担当者チェック
if (
!Number.isInteger(operatorId) ||
operatorId <= 0
) {
errors.operatorId = [
"担当者を選択してください。",
];
}
// ステータスチェック
if (![0, 1, 2].includes(status)) {
errors.status = [
"ステータスが正しくありません。",
];
}
// エラーがあればAPIを呼ばずに終了
if (Object.keys(errors).length > 0) {
return {
errors,
message:
"入力内容を確認してください。",
};
}
const request = {
taskName,
operatorId,
status,
plannedDate:
plannedDateText === ""
? null
: plannedDateText,
};
const response = await fetch(
`${process.env.API_BASE_URL}/api/Tasks`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
}
);
if (!response.ok) {
return {
errors: {},
message:
"タスクの登録に失敗しました。",
};
}
revalidatePath("/");
redirect("/");
}
フォームをClient Componentに分離する
useActionStateはReact Hook。
HookはClient Componentで使用するため、フォーム部分を別ファイルにする。
src/components/CreateTaskForm.tsxを作成する。
"use client";
import { useActionState } from "react";
import {
createTask,
type TaskActionState,
} from "@/app/actions/task-actions";
import type { Operator } from "@/types/operator";
type Props = {
operators: Operator[];
};
export default function CreateTaskForm({
operators,
}: Props) {
const initialState: TaskActionState = {
errors: {},
message: null,
};
const [
state,
formAction,
isPending,
] = useActionState(
createTask,
initialState
);
return (
<form action={formAction}>
<div>
<label htmlFor="taskName">
タスク名
</label>
<br />
<input
id="taskName"
name="taskName"
type="text"
maxLength={200}
aria-invalid={
!!state.errors?.taskName
}
/>
{state.errors?.taskName?.map(
(error) => (
<p
key={error}
style={{ color: "red" }}
>
{error}
</p>
)
)}
</div>
<div>
<label htmlFor="operatorId">
担当者
</label>
<br />
<select
id="operatorId"
name="operatorId"
defaultValue=""
aria-invalid={
!!state.errors?.operatorId
}
>
<option value="" disabled>
選択してください
</option>
{operators.map((operator) => (
<option
key={operator.id}
value={operator.id}
>
{operator.name}
</option>
))}
</select>
{state.errors?.operatorId?.map(
(error) => (
<p
key={error}
style={{ color: "red" }}
>
{error}
</p>
)
)}
</div>
<div>
<label htmlFor="status">
ステータス
</label>
<br />
<select
id="status"
name="status"
defaultValue="0"
>
<option value="0">
未着手
</option>
<option value="1">
進行中
</option>
<option value="2">
完了
</option>
</select>
</div>
<div>
<label htmlFor="plannedDate">
予定日
</label>
<br />
<input
id="plannedDate"
name="plannedDate"
type="date"
/>
</div>
{state.message && (
<p style={{ color: "red" }}>
{state.message}
</p>
)}
<div>
<button
type="submit"
disabled={isPending}
>
{isPending
? "登録中..."
: "登録"}
</button>
</div>
</form>
);
}
useActionStateの3つの戻り値
state
→ Server Actionが返した結果
formAction
→ formから呼ぶAction
isPending
→ 処理中かどうか
処理の流れ
<form>
↓
formAction
↓
useActionState
↓
createTask()
↓
返り値
↓
state
↓
画面更新
page.tsxを簡単にする
src/app/tasks/new/page.tsxを変更する。
import Link from "next/link";
import { getOperators } from "@/lib/operator-api";
import CreateTaskForm from "@/components/CreateTaskForm";
export default async function NewTaskPage() {
const operators = await getOperators();
return (
<main>
<h1>タスク新規登録</h1>
<CreateTaskForm
operators={operators}
/>
<p>
<Link href="/">
一覧へ戻る
</Link>
</p>
</main>
);
}
Server ComponentとClient Componentの役割
NewTaskPage
Server Component
│
│ GET /api/Operators
│
│ 担当者一覧取得
│
▼
CreateTaskForm
Client Component
│
│ useActionState
│ 入力・エラー表示
│
▼
createTask
Server Action
│
│ 入力チェック
│
│ POST
▼
ASP.NET Core API
│
▼
PostgreSQL
動作確認
タスクの新規登録でタスク名、担当者、予定日を入力しないで登録ボタンを押し、画面内にエラーが表示されることを確認。
新規登録と編集のフォームを共通化
完成形
新規登録ページ
↓
CreateTaskForm
↓
TaskFormFields ← 共通
↓
createTask
編集ページ
↓
EditTaskForm
↓
TaskFormFields ← 共通
↓
updateTask
task-actions.tsを整理する
src/app/actions/task-actions.tsを、以下の形にする。
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export type TaskActionState = {
errors?: {
taskName?: string[];
operatorId?: string[];
status?: string[];
plannedDate?: string[];
};
message?: string | null;
};
type TaskInput = {
taskName: string;
operatorId: number;
status: number;
plannedDate: string | null;
};
function getTaskInput(formData: FormData): TaskInput {
const taskName =
formData.get("taskName")?.toString().trim() ?? "";
const operatorId =
Number(formData.get("operatorId"));
const status =
Number(formData.get("status"));
const plannedDateText =
formData.get("plannedDate")?.toString() ?? "";
return {
taskName,
operatorId,
status,
plannedDate:
plannedDateText === ""
? null
: plannedDateText,
};
}
function validateTask(
input: TaskInput
): TaskActionState["errors"] {
const errors: TaskActionState["errors"] = {};
if (input.taskName === "") {
errors.taskName = [
"タスク名を入力してください。",
];
} else if (input.taskName.length > 200) {
errors.taskName = [
"タスク名は200文字以内で入力してください。",
];
}
if (
!Number.isInteger(input.operatorId) ||
input.operatorId <= 0
) {
errors.operatorId = [
"担当者を選択してください。",
];
}
if (![0, 1, 2].includes(input.status)) {
errors.status = [
"ステータスが正しくありません。",
];
}
return errors;
}
export async function createTask(
_previousState: TaskActionState,
formData: FormData
): Promise<TaskActionState> {
const input = getTaskInput(formData);
const errors = validateTask(input);
if (Object.keys(errors ?? {}).length > 0) {
return {
errors,
message: "入力内容を確認してください。",
};
}
const response = await fetch(
`${process.env.API_BASE_URL}/api/Tasks`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
}
);
if (!response.ok) {
return {
errors: {},
message: "タスクの登録に失敗しました。",
};
}
revalidatePath("/");
redirect("/");
}
export async function updateTask(
id: number,
_previousState: TaskActionState,
formData: FormData
): Promise<TaskActionState> {
const input = getTaskInput(formData);
const errors = validateTask(input);
if (Object.keys(errors ?? {}).length > 0) {
return {
errors,
message: "入力内容を確認してください。",
};
}
const response = await fetch(
`${process.env.API_BASE_URL}/api/Tasks/${id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
}
);
if (!response.ok) {
return {
errors: {},
message: "タスクの更新に失敗しました。",
};
}
revalidatePath("/");
redirect("/");
}
export async function deleteTask(
id: number,
_formData: FormData
) {
const response = await fetch(
`${process.env.API_BASE_URL}/api/Tasks/${id}`,
{
method: "DELETE",
}
);
if (!response.ok) {
throw new Error(
"タスクの削除に失敗しました。"
);
}
revalidatePath("/");
}
以前は createTask と updateTask の両方で、
const taskName = ...
const operatorId = ...
const status = ...
と同じ処理を書いていたのを、
FormData
↓
getTaskInput()
↓
TaskInput
として共通化。
入力チェックも、
createTask
┐
├→ validateTask()
│
updateTask
┘
として共通化。
共通フォーム部分を作る
src/components/TaskFormFields.tsxを作成する。
import type { Operator } from "@/types/operator";
import type { Task } from "@/types/task";
import type { TaskActionState } from "@/app/actions/task-actions";
type Props = {
operators: Operator[];
task?: Task;
state: TaskActionState;
};
export default function TaskFormFields({
operators,
task,
state,
}: Props) {
return (
<>
<div>
<label htmlFor="taskName">
タスク名
</label>
<br />
<input
id="taskName"
name="taskName"
type="text"
maxLength={200}
required
defaultValue={
task?.taskName ?? ""
}
aria-invalid={
!!state.errors?.taskName
}
/>
{state.errors?.taskName?.map(
(error) => (
<p
key={error}
style={{ color: "red" }}
>
{error}
</p>
)
)}
</div>
<div>
<label htmlFor="operatorId">
担当者
</label>
<br />
<select
id="operatorId"
name="operatorId"
required
defaultValue={
task?.operatorId ?? ""
}
aria-invalid={
!!state.errors?.operatorId
}
>
{!task && (
<option value="" disabled>
選択してください
</option>
)}
{operators.map((operator) => (
<option
key={operator.id}
value={operator.id}
>
{operator.name}
</option>
))}
</select>
{state.errors?.operatorId?.map(
(error) => (
<p
key={error}
style={{ color: "red" }}
>
{error}
</p>
)
)}
</div>
<div>
<label htmlFor="status">
ステータス
</label>
<br />
<select
id="status"
name="status"
defaultValue={
task?.status ?? 0
}
>
<option value="0">
未着手
</option>
<option value="1">
進行中
</option>
<option value="2">
完了
</option>
</select>
</div>
<div>
<label htmlFor="plannedDate">
予定日
</label>
<br />
<input
id="plannedDate"
name="plannedDate"
type="date"
defaultValue={
task?.plannedDate?.slice(0, 10) ?? ""
}
/>
</div>
{state.message && (
<p style={{ color: "red" }}>
{state.message}
</p>
)}
</>
);
}
task?: Task;によって、新規登録ではtask = なしなので、
タスク名 → 空
担当者 → 選択してください
状態 → 未着手
予定日 → 空
になる。
編集ではtask = ありなので、
タスク名 → 現在値
担当者 → 現在値
状態 → 現在値
予定日 → 現在値
になる。
新規登録フォームを変更
src/components/CreateTaskForm.tsxを変更する。
"use client";
import { useActionState } from "react";
import {
createTask,
type TaskActionState,
} from "@/app/actions/task-actions";
import type { Operator } from "@/types/operator";
import TaskFormFields
from "@/components/TaskFormFields";
type Props = {
operators: Operator[];
};
const initialState: TaskActionState = {
errors: {},
message: null,
};
export default function CreateTaskForm({
operators,
}: Props) {
const [
state,
formAction,
isPending,
] = useActionState(
createTask,
initialState
);
return (
<form action={formAction}>
<TaskFormFields
operators={operators}
state={state}
/>
<div>
<button
type="submit"
disabled={isPending}
>
{isPending
? "登録中..."
: "登録"}
</button>
</div>
</form>
);
}
編集フォームを新しく作る
src/components/EditTaskForm.tsxを作成する。
"use client";
import { useActionState } from "react";
import {
updateTask,
type TaskActionState,
} from "@/app/actions/task-actions";
import type { Operator } from "@/types/operator";
import type { Task } from "@/types/task";
import TaskFormFields
from "@/components/TaskFormFields";
type Props = {
task: Task;
operators: Operator[];
};
const initialState: TaskActionState = {
errors: {},
message: null,
};
export default function EditTaskForm({
task,
operators,
}: Props) {
const updateTaskWithId =
updateTask.bind(null, task.id);
const [
state,
formAction,
isPending,
] = useActionState(
updateTaskWithId,
initialState
);
return (
<form action={formAction}>
<TaskFormFields
task={task}
operators={operators}
state={state}
/>
<div>
<button
type="submit"
disabled={isPending}
>
{isPending
? "更新中..."
: "更新"}
</button>
</div>
</form>
);
}
編集ページを簡単にする
現在、src/app/tasks/[id]/edit/page.tsxにはフォームそのものを書いている。
これを次のように変更する。
import Link from "next/link";
import { getTask } from "@/lib/task-api";
import { getOperators } from "@/lib/operator-api";
import EditTaskForm
from "@/components/EditTaskForm";
export default async function EditTaskPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const taskId = Number(id);
const [task, operators] =
await Promise.all([
getTask(taskId),
getOperators(),
]);
return (
<main>
<h1>タスク編集</h1>
<EditTaskForm
task={task}
operators={operators}
/>
<p>
<Link href="/">
一覧へ戻る
</Link>
</p>
</main>
);
}