0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C# WinForms経験者がWebアプリケーションを開発してみた⑤~機能改善~

0
Last updated at Posted at 2026-09-26

前の記事

次の記事

フォームの入力エラーを画面内に表示する仕組みを作る

タスク新規登録

タスク名
[                    ]
タスク名を入力してください。

担当者
[ 選択してください ▼ ]

[登録]

のように画面内に入力エラーを表示する。

今回の仕組み

新規登録画面
    ↓
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>
  );
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?