![[管理画面フォーマット制作編 #3] Shadcn/uiで作るユーザ管理UI ─ 詳細・新規・編集フォーム実装](/_next/image?url=%2Farticles%2Fnext-js%2Fuser-management-ui%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット制作編 #3Shadcn/uiで作るユーザ管理UI ─ 詳細・新規・編集フォーム実装
管理者向けのユーザ詳細表示・新規登録・編集画面をShadcn/uiとReact Hook Form、Zodを組み合わせて実装
初回公開日
最終更新日
0. はじめに
本記事では、管理者向けのユーザ管理UIを構築します。対象となるページは次の4つです。
- 新規登録ページ
/users/new
- 詳細・編集ページ
/users/[displayId]
- 一覧ページ
/users
個別にゼロから作るのではなく、 共通フォーム基盤 (Shadcn/ui + React Hook Form + Zod)を最初に用意し、それを各ページへ適用する方針で進めます。共通化により、UIとバリデーションの重複を避け、保守性と実装速度を高めます。
本記事の前提(必読の過去記事)
-
Prisma × PostgreSQLで始めるユーザー・ロール管理
Account/Role/User のスキーマ、displayId
のDB自動採番(関数+シーケンス)、Prisma Client接続(lib/database.ts
)、ログインAPIのDB連携化などを実装済みです。ただ、 今回はUI制作のためテーブル設計だけを前提とします 。 -
Shadcn/uiで作るログイン後の管理画面レイアウト
Shadcn/ui「sidebar-07」をベースに、共通サイドバー/ヘッダ、ダークモード切替、/dashboard
レイアウトを構築済みです。 今回はこの記事の続きになります 。
💡以降は、上記のテーブル設計と管理画面レイアウトを前提に、ユーザ管理ページ群を実装します。
displayId
は DB側で自動採番 するため、新規作成フォームでは入力不要(非表示)、編集時は読み取り専用で表示します。技術スタック
Tool / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
Zod | 4.x | スキーマ定義と実行時バリデーション |
次章では、全体のページ構成とデータフローを整理し、共通フォーム基盤の要件を明確にします。
1. 実装の全体像とページ構成
今回構築するユーザ管理機能は、一覧/新規作成/詳細・編集 という管理画面の基本的な遷移パターンを備えます。
ただし実装は、以下の順序で進めます。
ただし実装は、以下の順序で進めます。
- 共通フォーム基盤の作成
/users/new
と/users/edit
で共通利用するUserForm
コンポーネントを作成- フィールド構成(name/email/password/role/isActive/account)とZodスキーマを統一
- 新規登録ページ
/users/new
- 空の初期値を設定し、
UserForm
を適用 - 送信後は一覧または詳細ページへ遷移(今回は仮遷移)
- 空の初期値を設定し、
- 詳細・編集ページ
/users/[displayId]
- 仮データを初期値として適用
- displayIdは読み取り専用で表示
- 一覧ページ
/users
- 仮データをテーブル表示
- 新規作成・詳細/編集へのリンクを設置
データフロー(UI実装前提)
- データ取得
- すべてのページで仮データを利用し、API接続やDBアクセスは行わない
- データ送信
- 新規登録/編集フォームは送信イベントだけ実装(実データ保存は行わない)
- ID管理
displayId
は仮データ内で用意(新規登録時は非表示、編集・詳細では表示専用)
この構成により、フォームUIとバリデーションロジックを共通化しつつ、ページ単位の役割分担を明確にできます。
次章では、この基盤となる共通フォームの作成から着手します。
次章では、この基盤となる共通フォームの作成から着手します。
2. 共通フォーム基盤の作成
最初に「新規」「編集」で共有する“型・バリデーション・UI部品”を用意します。ポイントは次の3つです。
- Zodで 単一のスキーマ を定義(型は
z.infer
で自動生成) - Shadcn/ui の
<Form>
を使って RHF コンテキストを供給 (最小構成では自前のFormProvider
は不要) - 入力部品は 同ファイル内の小さなコンポーネント に分割(必要になったら別ファイルへ切り出し)
管理画面は登録・編集フォームが多く、項目も似通います。退屈になりがちな作業を減らすため、共通基盤でできるだけ重複を排除します。
なお、今回はUI記事のため “UIのみ・仮データのみ” で進めます(API呼び出し等は行いません)。
なお、今回はUI記事のため “UIのみ・仮データのみ” で進めます(API呼び出し等は行いません)。
まず補足:z.infer
と <Form>
(RHFコンテキスト)の意味
■
Zodで書いた“実行時バリデーション定義(=スキーマ)”から、TypeScript型を自動生成する仕組みです。
「スキーマ = 真実の単一の出所(Single Source of Truth)」にすることで、型とバリデーションのズレを防げます。
z.infer
とはZodで書いた“実行時バリデーション定義(=スキーマ)”から、TypeScript型を自動生成する仕組みです。
「スキーマ = 真実の単一の出所(Single Source of Truth)」にすることで、型とバリデーションのズレを防げます。
- 実行時(Zod)…入力値の実チェック
- 開発時(TS 型)…エディタ補完・型エラー
→z.infer<typeof schema>
を使えば、二重定義ナシで両者が常に一致します。
tsx
1// ミニ例1:Zodスキーマ → z.infer で型を自動生成
2import { z } from "zod";
3
4const userSchema = z.object({
5 name: z.string().min(1, "必須"),
6 email: z.string().email("メール形式が不正"),
7 roleId: z.string().min(1, "必須"),
8});
9
10// “型” は重複定義せずに派生させる
11type UserFormValues = z.infer<typeof userSchema>;
12
13// 以降は UserFormValues を使えば、スキーマ変更に型も連動します。
■
最小構成では Shadcn/ui の
→ つまり、自前で
<Form>
(Shadcn/ui)とRHFコンテキスト最小構成では Shadcn/ui の
<Form>
コンポーネントを使います。<Form {...form}>
と書くだけで、内部で RHF の FormProvider
が仕込まれ、子の <FormField>
などから フォーム状態(control / errors など)にアクセス可能 になります。→ つまり、自前で
FormProvider
を用意する必要はありません(規模拡大時に必要なら差し替え可)。tsx
1// ミニ例2:<Form> を使った最小フォーム(自前の FormProvider は不要)
2"use client";
3
4import { useForm } from "react-hook-form";
5import {
6 Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
7} from "@/components/ui/form";
8import { Input } from "@/components/ui/input";
9
10type Values = { name: string };
11
12export default function ExampleForm() {
13 const form = useForm<Values>({ defaultValues: { name: "" } });
14
15 return (
16 <Form {...form}> {/* ← これでRHFコンテキストが供給される */}
17 <form onSubmit={form.handleSubmit((v) => console.log(v))} className="space-y-4">
18 <FormField
19 control={form.control}
20 name="name"
21 render={({ field }) => (
22 <FormItem>
23 <FormLabel>氏名</FormLabel>
24 <FormControl><Input {...field} placeholder="山田 太郎" /></FormControl>
25 <FormMessage />
26 </FormItem>
27 )}
28 />
29 <button type="submit">送信</button>
30 </form>
31 </Form>
32 );
33}
このあと実装では、Zodスキーマ → 型派生(
続く節で具体的なスキーマ定義と、モックデータ、共通フォームの実装へ進みます。
z.infer
)→ 共通フォームの順に手を動かしていきます。続く節で具体的なスキーマ定義と、モックデータ、共通フォームの実装へ進みます。
ディレクトリ構成とインストール(前提確認)
まず用語をひとことだけ:
- RHF は React Hook Form の略です。
useForm()
でフォームの「値・エラー・検証」を作り、FormProvider
で子コンポーネントへ配ります。これにより、小さな入力部品をページ間で再利用しやすくなります。
細かくファイルを分けすぎると後で「あれ? どこにこの記述ってあったけ?」となるので、今回は、 “スモール構成” でいきたいと思います。1ファイルにまとまっていれば、迷わず1箇所を開けばOKで、後から必要になったら分割できます。
◯ディレクトリ構成(スモール構成):
txt
1src/
2├─ lib/
3│ └─ users/
4│ ├─ schema.ts # Zodスキーマと z.infer 型(作成・更新の2種)
5│ └─ mock.ts # アカウント/ロール/ユーザの仮データ
6└─ components/
7 └─ users/
8 └─ user-form.tsx # 共通フォーム本体(小さなフィールドは同ファイル内に定義)
◯依存パッケージ(導入済みならスキップ):
zsh
1npx shadcn@latest add select switch
Shadcn/ui の
form
, input
, button
, card
, separator
などは「0 .はじめに」で記載した過去記事で追加済みを前提にしています。(不足があれば随時 npx shadcn@latest add <component>
で追加)Zodスキーマ定義(型の単一出所)
まずは「作成用」と「編集用」で求められる項目が少し違う点を踏まえて、Zodのスキーマを用意します。
このスキーマを真実の単一の出所にして、
このスキーマを真実の単一の出所にして、
z.infer
からTypeScript型を自動生成します。- 共通項目:
accountCode
,name
,email
,roleCode
,isActive
- 新規のみ必要:
password
(編集では扱わないためスキーマから除外) - 編集で表示のみ:
displayId
(DB自動採番のため、読み取り専用)
src/lib/users/schema.ts
を新規作成
ts
1// src/lib/users/schema.ts
2import { z } from "zod";
3
4/** ── 追加:ロールの定数と型 ── */
5export const ROLE_CODES = ["ADMIN", "EDITOR", "VIEWER"] as const;
6export type RoleCode = (typeof ROLE_CODES)[number];
7
8/** ── 入力ルール(数字はあとから見直しやすいよう定数化) ── */
9export const NAME_MAX = 100 as const;
10export const PASSWORD_MIN = 15 as const;
11export const PASSWORD_MAX = 128 as const;
12
13/** 共通フィールドの最小ルール */
14const nameSchema = z
15 .string()
16 .min(1, "氏名を入力してください")
17 .max(NAME_MAX, `${NAME_MAX}文字以内で入力してください`);
18
19// Zod v4 形式:z.email()
20const emailSchema = z.email("メールアドレスの形式が正しくありません");
21
22const roleCodeSchema = z.enum(ROLE_CODES, "ロールを選択してください"); // ← "ADMIN" | "EDITOR" | "VIEWER" になる
23
24/** ── 新規作成用:password が必須 ── */
25export const userCreateSchema = z.object({
26 name: nameSchema,
27 email: emailSchema,
28 roleCode: roleCodeSchema,
29 password: z
30 .string()
31 .min(PASSWORD_MIN, `${PASSWORD_MIN}文字以上で入力してください`)
32 .max(PASSWORD_MAX, `${PASSWORD_MAX}文字以内で入力してください`)
33 .regex(/[A-Z]/, "大文字を1文字以上含めてください。")
34 .regex(/[a-z]/, "小文字を1文字以上含めてください。")
35 .regex(/[0-9]/, "数字を1文字以上含めてください。"),
36 isActive: z.boolean().default(true),
37});
38
39/** ── 編集用:displayId を表示専用で扱い、password は扱わない ── */
40export const userUpdateSchema = z.object({
41 displayId: z.string().min(1, "表示IDの取得に失敗しました"),
42 name: nameSchema,
43 email: emailSchema,
44 roleCode: roleCodeSchema,
45 isActive: z.boolean().default(true),
46});
47
48/** ── Zod から型を派生(z.infer を使う) ── */
49export type UserCreateValues = z.infer<typeof userCreateSchema>;
50export type UserUpdateValues = z.infer<typeof userUpdateSchema>;
💡このファイルのポイント
- バリデーションの数字(長さなど)は定数化しておくと、運用途中の見直しが楽です。
userCreateSchema
とuserUpdateSchema
を分離し、「新規では password 必須/編集では扱わない」「編集では displayId を表示専用で保持」という要件をスキーマ側に明示します。これにより、フォームUIはスキーマに従うだけで分岐がシンプルになります。- 末尾の
UserCreateValues
/UserUpdateValues
はz.infer
による自動型生成。以後、画面やコンポーネントではこの型を使うことで、Zodの変更と型が常に一致します。
モックデータの用意(アカウント/ロール/ユーザ)
UIのみで動作確認するための 擬似データ置き場 を作ります。ポイントは次の3つです。
- ログイン中アカウントを
CURRENT_ACCOUNT_CODE
として擬似(= テナント境界:アカウントIDに紐づくものだけ編集・操作可能の代用) - ロールの選択肢は
RoleOption[]
を一元管理 - 一覧/詳細/新規・編集で使いやすいように 小さなユーティリティ関数 を同梱
ここでの
一覧・詳細も「ログイン中アカウントのデータだけ」を返す形にしています(モック段階でもテナント境界の癖をつけるため)。
accountCode
は UI からは入力しません。一覧・詳細も「ログイン中アカウントのデータだけ」を返す形にしています(モック段階でもテナント境界の癖をつけるため)。
src/lib/users/mock.ts
(新規作成)
ts
1// src/lib/users/mock.ts
2import type { UserCreateValues, UserUpdateValues, RoleCode } from "./schema";
3
4/** モックのユーザ型(DBレコードのイメージ) */
5export type MockUser = {
6 displayId: string; // 例: U00000001(DBでは自動採番想定)
7 accountCode: string; // テナント境界(ログインで決まる)
8 name: string;
9 email: string;
10 roleCode: RoleCode;
11 isActive: boolean;
12 deletedAt?: Date | null; // 論理削除用
13};
14
15/** ログイン中アカウント(UIのみなので定数で擬似) */
16export const CURRENT_ACCOUNT_CODE = "testAccount0123" as const;
17
18/** ロールの選択肢(セレクト用) */
19export type RoleOption = { value: RoleCode; label: string };
20
21export const mockRoleOptions: RoleOption[] = [
22 { value: "ADMIN", label: "管理者(ADMIN)" },
23 { value: "EDITOR", label: "編集者(EDITOR)" },
24 { value: "VIEWER", label: "閲覧者(VIEWER)" },
25];
26
27/** サンプルユーザ(他アカウント混在 = フィルタ確認用) */
28export const mockUsers: MockUser[] = [
29 {
30 displayId: "U00000001",
31 accountCode: "testAccount0123",
32 name: "山田 太郎",
33 email: "admin@example.com",
34 roleCode: "ADMIN",
35 isActive: true,
36 },
37 {
38 displayId: "U00000002",
39 accountCode: "testAccount0123",
40 name: "佐藤 花子",
41 email: "editor@example.com",
42 roleCode: "EDITOR",
43 isActive: true,
44 },
45 {
46 displayId: "U00000003",
47 accountCode: "testAccount0123",
48 name: "鈴木 一郎",
49 email: "viewer@example.com",
50 roleCode: "VIEWER",
51 isActive: true,
52 },
53 // 別アカウント(一覧などで “除外される” ことを確認するため)
54 {
55 displayId: "U00000011",
56 accountCode: "anotherAccount",
57 name: "別アカウント ユーザ",
58 email: "other@example.com",
59 roleCode: "VIEWER",
60 isActive: true,
61 },
62];
63
64/** ── 便利ユーティリティ ───────────────────────────── */
65
66/** 指定アカウント配下のユーザ一覧を取得(テナント境界の代用) */
67export function getUsersByAccount(accountCode: string): MockUser[] {
68 return mockUsers.filter((u) => u.accountCode === accountCode);
69}
70
71/** 指定アカウント配下で displayId に一致するユーザを1件取得 */
72export function getUserByDisplayId(
73 accountCode: string,
74 displayId: string,
75): MockUser | undefined {
76 return mockUsers.find(
77 (u) => u.accountCode === accountCode && u.displayId === displayId,
78 );
79}
80
81/** 編集フォームの初期値へ変換(accountCode はフォームでは扱わない) */
82export function toUpdateValues(user: MockUser): UserUpdateValues {
83 return {
84 displayId: user.displayId,
85 name: user.name,
86 email: user.email,
87 roleCode: user.roleCode,
88 isActive: user.isActive,
89 };
90}
91
92// 論理削除の擬似処理
93export function markDeleted(accountCode: string, displayId: string): boolean {
94 const u = mockUsers.find(
95 (x) => x.accountCode === accountCode && x.displayId === displayId,
96 );
97 if (!u) return false;
98 u.deletedAt = new Date();
99 return true;
100}
101
102/** 次の displayId をモック発番(本番はDBで採番) */
103function pad(n: number, width = 8) {
104 return n.toString().padStart(width, "0");
105}
106export function nextDisplayIdFor(accountCode: string): string {
107 const seq =
108 getUsersByAccount(accountCode)
109 .map((u) => Number(u.displayId.slice(1)))
110 .filter((n) => !Number.isNaN(n))
111 .reduce((max, n) => Math.max(max, n), 0) + 1;
112 return `U${pad(seq)}`;
113}
114
115/** 新規作成の擬似ペイロード作成(displayIdはモック発番、accountCode を注入) */
116export function composeCreatePayload(
117 values: UserCreateValues,
118 accountCode: string,
119): MockUser {
120 return {
121 displayId: nextDisplayIdFor(accountCode),
122 accountCode,
123 name: values.name,
124 email: values.email,
125 roleCode: values.roleCode,
126 isActive: values.isActive ?? true,
127 };
128}
💡補足:ユーティリティ早見表(src/lib/users/mock.ts
)
下記は UIだけでサクッと動かすための擬似関数 群です(本番はサーバ側でDB・認可を強制)。
テナント境界(
テナント境界(
accountCode
)を常に引数に取り、ログイン中アカウントのデータしか触れない癖付けをしています。名称 | 目的 / 使いどころ | 主な処理 | 備考 |
---|---|---|---|
getUsersByAccount | 一覧用に、ログイン中アカウント配下のユーザだけを取得 | mockUsers を filter | 実運用では WHERE account_id = ? に相当(サーバ側で強制) |
getUserByDisplayId | 詳細/編集で1件取得 | mockUsers を find | 他アカウントのIDはヒットしない(テナント境界) |
toUpdateValues | 編集フォームの初期値へ変換 | displayId/name/email/roleCode/isActive を抽出 | フォームでは accountCode を扱わない方針を徹底 |
markDeleted | 論理削除の疑似的な処理 | deletedAt に new Date() をセットする | 他アカウントのIDはヒットしない(テナント境界) |
nextDisplayIdFor | 新規作成時の 表示ID(U00000001 形式)を擬似採番 | 同一アカウント内の displayId から最大数値を抽出→+1→ゼロ埋め | 本番はDBのシーケンス/関数で採番(競合対策はDBに委譲) |
composeCreatePayload | フォーム値+アカウントで 新規レコード形のモック を作る | nextDisplayIdFor() で displayId を発番し合体 | 本番では Account.code → accountId 、Role.code → roleId をサーバ側で解決 |
mockRoleOptions | ロールの選択肢(セレクト) | 値集合:ADMIN / EDITOR / VIEWER をラベル化 | RoleCode 型(z.enum )と同一の値集合で安全に |
メモ:
これにより、セレクトの値・バリデーション・型が完全一致し、将来ロール追加も1箇所変更で済みます。
roleCode
は schema.ts
の z.enum(ROLE_CODES)
が唯一の出所です(RoleCode
型)。これにより、セレクトの値・バリデーション・型が完全一致し、将来ロール追加も1箇所変更で済みます。
💡使用例(抜粋)
tsx
1import {
2 CURRENT_ACCOUNT_CODE,
3 mockRoleOptions,
4 getUsersByAccount,
5 getUserByDisplayId,
6 toUpdateValues,
7 composeCreatePayload,
8} from "@/lib/users/mock";
9import type { UserCreateValues } from "@/lib/users/schema";
10
11// 一覧:ログイン中アカウントのみ
12const users = getUsersByAccount(CURRENT_ACCOUNT_CODE);
13
14// 詳細/編集:アカウント境界をまたがない取得
15const user = getUserByDisplayId(CURRENT_ACCOUNT_CODE, "U00000001");
16
17// 編集フォームの初期値へ変換
18const initialValues = user ? toUpdateValues(user) : undefined;
19
20// 新規作成:displayId をモック発番しつつ accountCode を合体
21function handleCreate(values: UserCreateValues) {
22 const payload = composeCreatePayload(values, CURRENT_ACCOUNT_CODE);
23 console.log(payload);
24}
このユーティリティは UI だけを快適に回すためのものです。本番では DB 側で
displayId
採番・WHERE account_id = ?
によるテナント制約をサーバ側で強制します3. 共通フォーム(<UserForm />
)の実装
ここから、新規と編集の両方で使い回せる共通フォームを作ります。
スモール構成(1ファイル内に小さなフィールド部品を内包)で始め、必要になったら分割する方針です。UIは Shadcn/ui の
スモール構成(1ファイル内に小さなフィールド部品を内包)で始め、必要になったら分割する方針です。UIは Shadcn/ui の
<Form>
を使って RHF(React Hook Form)と橋渡し、バリデーションは 2-2 の Zod スキーマ(userCreateSchema
/ userUpdateSchema
)に全面委譲します。◯完成イメージ
- 新規:氏名/メール/ロール/パスワード/有効フラグ(
displayId
はなし) - 編集:
displayId(読み取り専用)
/氏名/メール/ロール/有効フラグ(パスワードは扱わない) - 共通:エラー表示・バリデーション・ボタン配置は同一仕様
◯コンポーネントの役割
- フィールド群の描画、RHF とのバインド、Zod によるエラー表示
- 送信時に 正しい型(
UserCreateValues
/UserUpdateValues
)でonSubmit
に委譲 - API呼び出しやルーティングは行わない(ページ側が担当)
accountCode
は扱わない(ログイン文脈でページ側が注入)
◯
<UserForm />
のインターフェース(暫定仕様)Prop | 型 | 必須 | 説明 |
---|---|---|---|
mode | "create" | "edit" | ✓ | 新規/編集を切り替え(表示フィールドとスキーマが変わる) |
roleOptions | { value: RoleCode; label: string }[] | ✓ | ロールの選択肢(z.enum(ROLE_CODES) と同一集合) |
onSubmit | (values: UserCreateValues | UserUpdateValues) => void | ✓ | 送信時に呼ばれる。ページ側で accountCode を合体し、遷移などを行う |
onCancel | () => void | キャンセル時の挙動(任意。未指定なら何もしない) | |
initialValues | Partial<UserUpdateValues> | 編集時の初期値(mode="edit" のときのみ参照) |
◯表示/入力ルール(抜粋)
項目 | 新規 | 編集 | 備考 |
---|---|---|---|
displayId | - | 読み取り専用で表示 | DB自動採番の表示用ID |
name | 入力 | 入力 | 必須・最大 NAME_MAX 文字 |
email | 入力 | 入力 | 必須・z.email |
roleCode | 選択 | 選択 | RoleCode (ADMIN /EDITOR /VIEWER ) |
password | 入力 | - | 15文字以上+大/小/数字の混在 |
isActive | 切替 | 切替 | 既定 true |
accountCode | 表示しない | 表示しない | ページ側で注入(テナント境界) |
◯バリデーション挙動
- スキーマは 単一の出所(2-2 の
userCreateSchema
/userUpdateSchema
) - フォームモードに応じて resolver を切替(作成:
userCreateSchema
、編集:userUpdateSchema
) - 入力タイミングは基本 onBlur、送信時にも再検証
- パスワード要件(15文字以上・大/小/数字)は 失敗理由を個別に表示(それぞれの
.regex
メッセージ)
◯アクセシビリティと操作性
- 各フィールドに
<FormLabel>
と<FormMessage>
を付与(ラベルとエラーの関連付け) - セレクト・スイッチはキーボード操作対応(Shadcn/ui の既定挙動)
- 送信ボタンはフォーム内最後、キャンセルは
variant="outline"
で区別
このあと、具体的なファイルと JSX 構造を示します。
コンポーネント方針(スモール構成・同ファイル内フィールド)
方針は「1つの
RHF の
<UserForm />
を、mode
によって新規/編集を出し分ける」。RHF の
useForm
はモードごとに別スキーマ(create/update)を使いたいので、内部で CreateForm
/ EditForm
の2コンポーネントに分岐して実装します。これで any
を使わずに、onSubmit
の引数型もそれぞれ正しく(UserCreateValues
/ UserUpdateValues
)にできます。- スモール構成:1ファイル内に「小さなフィールド部品(氏名/メール/ロール/パスワード/有効)」を内包
FormProvider
は使わず、RHFのcontrol
を各<FormField>
に直接渡す(小規模向け)mode="create"
のときだけパスワードを表示、mode="edit"
のときはdisplayId
を読み取り専用表示roleCode
はz.enum(ROLE_CODES)
による リテラル合併型(型・選択肢・バリデーションの単一出所)
また、データの論理削除についても、編集コンポーネントの部分で導線を設置します。このとき、アラートを出したいので、
Shadcn/UI
のAlert Dialog
を利用します。下記のコマンドでインストールしておきます。zsh
1npx shadcn@latest add alert-dialog
src/components/users/user-form.tsx
(新規作成)
tsx
1// src/components/users/user-form.tsx
2"use client";
3
4import * as React from "react";
5import { useForm } from "react-hook-form";
6import { zodResolver } from "@hookform/resolvers/zod";
7
8import {
9 Form,
10 FormField,
11 FormItem,
12 FormLabel,
13 FormControl,
14 FormMessage,
15 FormDescription,
16} from "@/components/ui/form";
17import { Input } from "@/components/ui/input";
18import {
19 Select,
20 SelectTrigger,
21 SelectValue,
22 SelectContent,
23 SelectItem,
24} from "@/components/ui/select";
25import { Switch } from "@/components/ui/switch";
26import { Button } from "@/components/ui/button";
27import { Card, CardContent, CardFooter } from "@/components/ui/card";
28import { Separator } from "@/components/ui/separator";
29import {
30 AlertDialog,
31 AlertDialogAction,
32 AlertDialogCancel,
33 AlertDialogContent,
34 AlertDialogDescription,
35 AlertDialogFooter,
36 AlertDialogHeader,
37 AlertDialogTitle,
38 AlertDialogTrigger,
39} from "@/components/ui/alert-dialog";
40import { Eye, EyeOff } from "lucide-react";
41
42import {
43 type RoleCode,
44 userCreateSchema,
45 userUpdateSchema,
46 type UserCreateValues,
47 type UserUpdateValues,
48 NAME_MAX,
49 PASSWORD_MIN,
50} from "@/lib/users/schema";
51
52/* =========================
53 公開インターフェース(型)
54 ========================= */
55
56export type RoleOption = { value: RoleCode; label: string };
57
58type BaseProps = {
59 roleOptions: RoleOption[];
60 onCancel?: () => void;
61 onDelete?: () => void;
62};
63
64type CreateProps = BaseProps & {
65 mode: "create";
66 onSubmit: (values: UserCreateValues) => void;
67 initialValues?: never;
68};
69
70type EditProps = BaseProps & {
71 mode: "edit";
72 onSubmit: (values: UserUpdateValues) => void;
73 // 読み取り専用 displayId を含む完全な初期値を推奨
74 initialValues: UserUpdateValues;
75};
76
77type Props = CreateProps | EditProps;
78
79/* =========================
80 エクスポート本体
81 ========================= */
82
83export default function UserForm(props: Props) {
84 return props.mode === "create" ? (
85 <CreateForm {...props} />
86 ) : (
87 <EditForm {...props} />
88 );
89}
90
91/* =========================
92 Create(新規)フォーム
93 ========================= */
94
95function CreateForm({ roleOptions, onSubmit, onCancel }: CreateProps) {
96 const form = useForm<UserCreateValues>({
97 resolver: zodResolver(userCreateSchema),
98 defaultValues: { name: "", email: "", password: "", isActive: true },
99 mode: "onBlur",
100 });
101
102 const handleSubmit = form.handleSubmit(onSubmit);
103
104 return (
105 <Form {...form}>
106 <form data-testid="user-form-create" onSubmit={handleSubmit}>
107 <Card className="w-full rounded-md">
108 <CardContent className="space-y-6 pt-1">
109 <NameField />
110 <EmailField />
111 <RoleField roleOptions={roleOptions} />
112 <PasswordField />
113 <IsActiveField />
114 </CardContent>
115
116 <CardFooter className="mt-4 flex gap-2">
117 <Button
118 type="button"
119 variant="outline"
120 onClick={onCancel}
121 data-testid="cancel-btn"
122 className="cursor-pointer"
123 >
124 キャンセル
125 </Button>
126 <Button
127 type="submit"
128 data-testid="submit-create"
129 className="cursor-pointer"
130 disabled={form.formState.isSubmitting}
131 >
132 登録する
133 </Button>
134 </CardFooter>
135 </Card>
136 </form>
137 </Form>
138 );
139}
140
141/* =========================
142 Edit(編集)フォーム
143 ========================= */
144
145function EditForm({
146 roleOptions,
147 onSubmit,
148 onCancel,
149 onDelete,
150 initialValues,
151}: EditProps) {
152 const form = useForm<UserUpdateValues>({
153 resolver: zodResolver(userUpdateSchema),
154 defaultValues: initialValues,
155 mode: "onBlur",
156 });
157
158 const handleSubmit = form.handleSubmit(onSubmit);
159
160 return (
161 <Form {...form}>
162 <form data-testid="user-form-edit" onSubmit={handleSubmit}>
163 <Card className="w-full rounded-md">
164 <CardContent className="space-y-6 pt-1">
165 <DisplayIdField />
166 <Separator />
167 <NameField />
168 <EmailField />
169 <RoleField roleOptions={roleOptions} />
170 <IsActiveField />
171 </CardContent>
172
173 <CardFooter className="mt-4 flex items-center justify-between">
174 <div className="flex gap-2">
175 <Button
176 type="button"
177 variant="outline"
178 onClick={onCancel}
179 data-testid="cancel-btn"
180 className="cursor-pointer"
181 >
182 キャンセル
183 </Button>
184 <Button
185 type="submit"
186 data-testid="submit-update"
187 className="cursor-pointer"
188 disabled={form.formState.isSubmitting}
189 >
190 更新する
191 </Button>
192 </div>
193 {/* onDelete が渡っている時だけ表示 */}
194 {onDelete && (
195 <AlertDialog>
196 <AlertDialogTrigger asChild>
197 <Button
198 type="button"
199 variant="destructive"
200 data-testid="delete-open"
201 className="cursor-pointer"
202 >
203 削除する
204 </Button>
205 </AlertDialogTrigger>
206 <AlertDialogContent>
207 <AlertDialogHeader>
208 <AlertDialogTitle>
209 ユーザを論理削除しますか?
210 </AlertDialogTitle>
211 <AlertDialogDescription>
212 この操作は DB では <code>deletedAt</code>{" "}
213 を設定する「論理削除」です。
214 一覧からは非表示になります(復活は別途機能で対応)。
215 </AlertDialogDescription>
216 </AlertDialogHeader>
217
218 <AlertDialogFooter>
219 <AlertDialogCancel data-testid="delete-cancel">
220 キャンセル
221 </AlertDialogCancel>
222 <AlertDialogAction
223 onClick={onDelete}
224 data-testid="delete-confirm"
225 >
226 削除する
227 </AlertDialogAction>
228 </AlertDialogFooter>
229 </AlertDialogContent>
230 </AlertDialog>
231 )}
232 </CardFooter>
233 </Card>
234 </form>
235 </Form>
236 );
237}
238
239/* =========================
240 小さなフィールド群(同ファイル内)
241 - RHF の form は Form コンポーネントから context 供給済み
242 - FormField は useForm の control を内部取得
243 ========================= */
244
245// 氏名
246function NameField() {
247 return (
248 <FormField
249 name="name"
250 render={({ field }) => (
251 <FormItem>
252 <FormLabel className="font-semibold">氏名 *</FormLabel>
253 <FormControl>
254 <Input
255 {...field}
256 inputMode="text"
257 placeholder="山田 太郎"
258 maxLength={NAME_MAX}
259 aria-label="氏名"
260 autoComplete="off"
261 data-testid="name"
262 />
263 </FormControl>
264 <FormMessage data-testid="name-error" />
265 </FormItem>
266 )}
267 />
268 );
269}
270
271// メール
272function EmailField() {
273 return (
274 <FormField
275 name="email"
276 render={({ field }) => (
277 <FormItem>
278 <FormLabel className="font-semibold">メールアドレス *</FormLabel>
279 <FormControl>
280 <Input
281 type="email"
282 {...field}
283 placeholder="user@example.com"
284 aria-label="メールアドレス"
285 autoComplete="off"
286 data-testid="email"
287 />
288 </FormControl>
289 <FormMessage data-testid="email-error" />
290 </FormItem>
291 )}
292 />
293 );
294}
295
296// ロール
297function RoleField({ roleOptions }: { roleOptions: RoleOption[] }) {
298 return (
299 <FormField
300 name="roleCode"
301 render={({ field }) => (
302 <FormItem>
303 <FormLabel className="font-semibold">ロール *</FormLabel>
304 <Select
305 name={field.name}
306 value={field.value ?? ""}
307 onValueChange={(v) => field.onChange(v as RoleCode)}
308 >
309 <FormControl>
310 <SelectTrigger
311 aria-label="ロールを選択"
312 data-testid="role-trigger"
313 >
314 <SelectValue
315 placeholder="選択してください"
316 data-testid="role-value"
317 />
318 </SelectTrigger>
319 </FormControl>
320 {/* Portal配下でも拾えるようにリスト自体に testid を付与 */}
321 <SelectContent data-testid="role-list">
322 {roleOptions.map((opt) => (
323 <SelectItem
324 key={opt.value}
325 value={opt.value}
326 data-testid={`role-item-${opt.value.toLowerCase()}`}
327 >
328 {opt.label}
329 </SelectItem>
330 ))}
331 </SelectContent>
332 </Select>
333 <FormMessage data-testid="roleCode-error" />
334 </FormItem>
335 )}
336 />
337 );
338}
339
340// パスワード(新規のみ・表示/非表示トグル付き)
341function PasswordField() {
342 const [showPassword, setShowPassword] = React.useState(false);
343
344 return (
345 <FormField
346 name="password"
347 render={({ field }) => (
348 <FormItem>
349 <FormLabel className="font-semibold">パスワード *</FormLabel>
350 <div className="flex items-start gap-2">
351 <FormControl>
352 <Input
353 {...field}
354 data-testid="password"
355 type={showPassword ? "text" : "password"}
356 autoComplete="off"
357 placeholder={`${PASSWORD_MIN}文字以上(英大/小/数字を含む)`}
358 aria-label="パスワード"
359 />
360 </FormControl>
361
362 <Button
363 data-testid="password-toggle"
364 type="button"
365 size="icon"
366 variant="outline"
367 onClick={() => setShowPassword((prev) => !prev)}
368 aria-label={
369 showPassword
370 ? "パスワードを非表示にする"
371 : "パスワードを表示する"
372 }
373 className="shrink-0 cursor-pointer"
374 >
375 {showPassword ? (
376 <EyeOff className="size-4" />
377 ) : (
378 <Eye className="size-4" />
379 )}
380 </Button>
381 </div>
382 <FormMessage data-testid="password-error" />
383 </FormItem>
384 )}
385 />
386 );
387}
388
389// 有効フラグ
390function IsActiveField() {
391 return (
392 <FormField
393 name="isActive"
394 render={({ field }) => (
395 <FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
396 <div className="space-y-0.5">
397 <FormLabel className="font-semibold">有効 *</FormLabel>
398 <FormDescription>
399 ONにすると有効/OFFにすると無効になります
400 </FormDescription>
401 </div>
402 <FormControl>
403 <Switch
404 name={field.name}
405 checked={Boolean(field.value)}
406 onCheckedChange={field.onChange}
407 aria-label="有効フラグ"
408 data-testid="isActive"
409 />
410 </FormControl>
411 <FormMessage data-testid="isActive-error" />
412 </FormItem>
413 )}
414 />
415 );
416}
417
418// 表示ID(編集のみ・読み取り専用)
419function DisplayIdField() {
420 return (
421 <FormField
422 name="displayId"
423 render={({ field }) => (
424 <FormItem>
425 <FormLabel>表示ID</FormLabel>
426 <FormControl>
427 <Input
428 {...field}
429 readOnly
430 aria-readonly="true"
431 data-testid="displayId"
432 />
433 </FormControl>
434 <FormDescription data-testid="displayId-desc">
435 DBで自動採番される表示用IDです(編集不可)。
436 </FormDescription>
437 <FormMessage data-testid="displayId-error" />
438 </FormItem>
439 )}
440 />
441 );
442}
💡ポイント整理
- エクスポートは 単一の
<UserForm />
。内部でmode
によりCreateForm
/EditForm
を切替え、any
を使わずに型を確定。 - フィールド部品は同ファイル内に集約。共通の見た目・バリデーション結果表示(
<FormMessage>
)を統一。 Select
は 完全に制御(value
/onValueChange
)し、RoleCode
の値集合と一致。- ボタンは type="button" にし、
handleSubmit
経由で送信。ページ側でaccountCode
を注入して処理へ接続します。 - 大規模化したら、これらの小さなフィールドを
/components/users/fields/
に抜き出すだけで拡張可能です。 - 各パーツに
data-testid
を付与しています。これはE2Eテスト用に設置しています。なくても問題はありません。
4. 新規登録ページ(/users/new
)
この章では、
/users/new
に「新規登録フォームページ」を作ります。ポイントは次の2つです。- ページ側でログイン中テナントの
accountCode
を保持(仮に固定値)し、送信時にcomposeCreatePayload(values, accountCode)
で合成。 - 保存処理はまだ行わず、UIのみ(トースト通知)で動作確認。
下図のようなUIを作成していきます。

ルーティングとレイアウト
/users/new
用のページを追加します。/dashboard/page.tsx
のレイアウト(SidebarProvider
/ AppSidebar
/ SidebarInset
/ パンくず)をそのまま踏襲します。page.tsx
は Server Component、送信処理は Client Component(client.tsx
) に分離します。さらに、ここでログイン後の共通レイアウト(
layout.tsx
)を作成します。txt
1src/
2└─ app/
3 └─ (protected) # ログイン後の共通レイアウト領域(URLには出ない)
4 ├─ layout.tsx # ログイン後のレイアウト
5 ├─ dashboard/ # ここに移動
6 │ └─ page.tsx # まだ仮ページ状態のまま
7 └─ users/
8 └─ new/
9 ├─ page.tsx # SSR(サーバーコンポーネント)
10 └─ client.tsx # CSR(クライアント軽量ラッパー)
トースト表示の導入
成功、失敗をコンソール表示させるのではなく、トースト通知でわかりやすくします。下記のコマンドで
Shadcn/ui
のSonner
を利用できるようにします。zsh
1npx shadcn@latest add sonner
これを
src/app/(protected)/layout.tsx
等で利用していきます。src/app/(protected)/layout.tsx
の作成
ここで、ログインの共通パーツであるダークモード切り替えとトースト通知を設定します。
AppSidebar
などサイドバーパーツも共通化できるといいのですが、今後、メニューと表示中のページを同期してメニューを太字にするなどの処理する関係で、最小限の設定にとどめます。トースト通知は、
page.tsx
側に記載してもいいのですが、ページ遷移しても通知を表示させるにはlayout.tsx
に設置させる必要があります。tsx
1// src/app/(protected)/layout.tsx
2import type { Metadata } from "next";
3import { SidebarProvider } from "@/components/ui/sidebar";
4import { Toaster } from "@/components/ui/sonner";
5
6export const metadata: Metadata = {
7 title: "管理画面 | DELOGs",
8 description: "ログイン後の共通レイアウト",
9};
10
11export default function ProtectedLayout({
12 children,
13}: {
14 children: React.ReactNode;
15}) {
16 return (
17 <SidebarProvider>
18 {/* サイドバー/ヘッダ/パンくずは“各 page.tsx”で自由に */}
19 {children}
20 <Toaster richColors closeButton />
21 </SidebarProvider>
22 );
23}
src/app/users/new/client.tsx
の作成
tsx
1"use client";
2import { useRouter } from "next/navigation";
3
4import UserForm, { type RoleOption } from "@/components/users/user-form";
5import { composeCreatePayload } from "@/lib/users/mock";
6import { toast } from "sonner";
7
8type Props = {
9 roleOptions: RoleOption[];
10 accountCode: string;
11};
12
13export default function NewUserClient({ roleOptions, accountCode }: Props) {
14 const router = useRouter();
15 return (
16 <UserForm
17 mode="create"
18 roleOptions={roleOptions}
19 onSubmit={(values) => {
20 // UIのみ:フォーム値 + accountCode を合成して擬似レコード生成
21 const payload = composeCreatePayload(values, accountCode);
22 // トースト通知
23 toast.success("ユーザを作成しました", {
24 description: `ID: ${payload.displayId} / ${payload.email} / ロール: ${payload.roleCode}`,
25 duration: 3500,
26 });
27 // 成功したら、一覧ページへ遷移(まだ遷移先が未作成なので一旦コメントアウト
28 // router.push("/users");
29 }}
30 onCancel={() => history.back()}
31 />
32 );
33}
💡ポイント
mode="create"
で新規登録に設定するだけで、登録フォームを表示できるようになります。- UIだけなので、バリデーションをクリアしたら成功扱い
- 成功したら、トースト通知で確認できる
src/app/(protected)/users/new/page.tsx
の作成
フォーム部分を呼び出すだけのページを作成します。
tsx
1// src/app/(protected)/users/new/page.tsx
2import type { Metadata } from "next";
3import { AppSidebar } from "@/components/sidebar/app-sidebar";
4import { SidebarInset } from "@/components/ui/sidebar";
5
6import {
7 Breadcrumb,
8 BreadcrumbItem,
9 BreadcrumbLink,
10 BreadcrumbList,
11 BreadcrumbPage,
12 BreadcrumbSeparator,
13} from "@/components/ui/breadcrumb";
14import { Separator } from "@/components/ui/separator";
15import { SidebarTrigger } from "@/components/ui/sidebar";
16
17import Client from "./client";
18import { mockRoleOptions } from "@/lib/users/mock";
19
20const ACCOUNT_CODE = "testAccount0123"; // UIのみの仮固定
21
22export const metadata: Metadata = {
23 title: "ユーザ新規登録 | 管理画面レイアウト【DELOGs】",
24 description:
25 "共通フォーム(shadcn/ui + React Hook Form + Zod)でユーザを新規作成",
26};
27
28export default function Page() {
29 return (
30 <>
31 <AppSidebar />
32 <SidebarInset>
33 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
34 <div className="flex items-center gap-2 px-4">
35 <SidebarTrigger className="-ml-1" />
36 <Separator
37 orientation="vertical"
38 className="mr-2 data-[orientation=vertical]:h-4"
39 />
40 <Breadcrumb>
41 <BreadcrumbList>
42 <BreadcrumbItem className="hidden md:block">
43 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink>
44 </BreadcrumbItem>
45 <BreadcrumbSeparator className="hidden md:block" />
46 <BreadcrumbItem>
47 <BreadcrumbPage>ユーザ新規登録</BreadcrumbPage>
48 </BreadcrumbItem>
49 </BreadcrumbList>
50 </Breadcrumb>
51 </div>
52 </header>
53
54 <div className="max-w-xl p-4 pt-0">
55 <Client roleOptions={mockRoleOptions} accountCode={ACCOUNT_CODE} />
56 </div>
57 </SidebarInset>
58 </>
59 );
60}
(番外)src/app/(protected)/dashboard/page.tsx
の修正
過去記事で
dashboard/page.tsx
を作成した方は下記のように修正してください。tsx
1// src/app/(protected)/dashboard/page.tsx
2import type { Metadata } from "next";
3import { AppSidebar } from "@/components/sidebar/app-sidebar";
4import { SidebarInset } from "@/components/ui/sidebar";
5
6import {
7 Breadcrumb,
8 BreadcrumbItem,
9 BreadcrumbLink,
10 BreadcrumbList,
11 BreadcrumbPage,
12 BreadcrumbSeparator,
13} from "@/components/ui/breadcrumb";
14import { Separator } from "@/components/ui/separator";
15import { SidebarTrigger } from "@/components/ui/sidebar";
16
17export const metadata: Metadata = {
18 title: "ダッシュボードページ | 管理画面レイアウト【DELOGs】",
19 description: "shadcn/uiを使用した管理画面レイアウトのダッシュボードページ",
20};
21
22export default function Page() {
23 return (
24 <>
25 <AppSidebar />
26 <SidebarInset>
27 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
28 <div className="flex items-center gap-2 px-4">
29 <SidebarTrigger className="-ml-1" />
30 <Separator
31 orientation="vertical"
32 className="mr-2 data-[orientation=vertical]:h-4"
33 />
34 <Breadcrumb>
35 <BreadcrumbList>
36 <BreadcrumbItem className="hidden md:block">
37 <BreadcrumbLink href="#">
38 Building Your Application
39 </BreadcrumbLink>
40 </BreadcrumbItem>
41 <BreadcrumbSeparator className="hidden md:block" />
42 <BreadcrumbItem>
43 <BreadcrumbPage>Data Fetching</BreadcrumbPage>
44 </BreadcrumbItem>
45 </BreadcrumbList>
46 </Breadcrumb>
47 </div>
48 </header>
49 <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
50 <div className="grid auto-rows-min gap-4 md:grid-cols-3">
51 <div className="bg-muted/50 aspect-video rounded-xl" />
52 <div className="bg-muted/50 aspect-video rounded-xl" />
53 <div className="bg-muted/50 aspect-video rounded-xl" />
54 </div>
55 <div className="bg-muted/50 min-h-[100vh] flex-1 rounded-xl md:min-h-min" />
56 </div>
57 </SidebarInset>
58 </>
59 );
60}
ここで、
npm run dev
で動作を確認してみてください。/users/new
にアクセスすると、下図のようになります。バリデーションをクリアすれば、トースト通知が表示されます。
5. 編集ページ(/users/[displayId]
)
編集対象を一意に特定するため、動的セグメント(
UIは新規と同じ 共通フォーム
[displayId]
)でページを作ります。UIは新規と同じ 共通フォーム
<UserForm />
を使い、mode="edit"
と 初期値 initialValues
を渡すだけで切り替わります。以降、編集ページは
/users/[displayId]
とします(displayId
をURLで受け取るため)。下図のようなUIを作成していきます。

ルーティングとファイル配置
txt
1src/
2└─ app/
3 └─ (protected)/
4 └─ users/
5 └─ [displayId]/
6 ├─ page.tsx # SSR:初期値をモックから取得してクライアントへ橋渡し
7 └─ client.tsx # CSR:<UserForm mode="edit" ...> を描画、送信時にトースト
src/app/(protected)/users/[displayId]/client.tsx
の作成
<UserForm mode="edit" initialValues={...}>
を描画- 送信時は UIのみ:成功トーストを表示
tsx
1// src/app/(protected)/users/[displayId]/client.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { toast } from "sonner";
6import UserForm, { type RoleOption } from "@/components/users/user-form";
7import type { UserUpdateValues } from "@/lib/users/schema";
8import { markDeleted } from "@/lib/users/mock";
9
10type Props = {
11 initialValues: UserUpdateValues;
12 roleOptions: RoleOption[];
13 accountCode: string;
14};
15
16export default function EditUserClient({
17 initialValues,
18 roleOptions,
19 accountCode,
20}: Props) {
21 const router = useRouter();
22 return (
23 <UserForm
24 mode="edit"
25 initialValues={initialValues}
26 roleOptions={roleOptions}
27 onSubmit={(values) => {
28 // UIのみ:成功扱いでトースト
29 toast.success("ユーザを更新しました", {
30 description: `ID: ${values.displayId} / ${values.email} / ロール: ${values.roleCode} / 有効: ${values.isActive ? "ON" : "OFF"}`,
31 duration: 3500,
32 });
33 // 例:router.push(`/users/${values.displayId}`) などは一覧/詳細実装後に
34 }}
35 onCancel={() => history.back()}
36 onDelete={() => {
37 const ok = markDeleted(accountCode, initialValues.displayId);
38 if (ok) {
39 toast.success("ユーザを論理削除しました", {
40 description: `ID: ${initialValues.displayId}`,
41 });
42 // router.push("/users"); //一覧作成まではいったんコメントアウト
43 } else {
44 toast.error("削除に失敗しました");
45 }
46 }}
47 />
48 );
49}
src/app/(protected)/users/[displayId]/page.tsx
の作成
- URL の
params.displayId
を受け取る - ログイン中のアカウント(モック)
CURRENT_ACCOUNT_CODE
とセットで テナント境界 を守りつつ1件取得 toUpdateValues()
で 編集フォームの初期値 に整形- 見つからなければ notFound()(= 404)
tsx
1// src/app/(protected)/users/[displayId]/page.tsx
2import type { Metadata } from "next";
3import { notFound } from "next/navigation";
4import { AppSidebar } from "@/components/sidebar/app-sidebar";
5import { SidebarInset } from "@/components/ui/sidebar";
6
7import {
8 Breadcrumb,
9 BreadcrumbItem,
10 BreadcrumbLink,
11 BreadcrumbList,
12 BreadcrumbPage,
13 BreadcrumbSeparator,
14} from "@/components/ui/breadcrumb";
15import { Separator } from "@/components/ui/separator";
16import { SidebarTrigger } from "@/components/ui/sidebar";
17
18import Client from "./client";
19import {
20 CURRENT_ACCOUNT_CODE,
21 getUserByDisplayId,
22 toUpdateValues,
23 mockRoleOptions,
24} from "@/lib/users/mock";
25
26export const metadata: Metadata = {
27 title: "ユーザ編集 | 管理画面レイアウト【DELOGs】",
28 description: "共通フォーム(shadcn/ui + RHF + Zod)でユーザ情報を編集",
29};
30
31export default async function Page({
32 params,
33}: {
34 params: Promise<{ displayId: string }>;
35}) {
36 const accountCode = CURRENT_ACCOUNT_CODE; // UIのみの仮固定
37 const { displayId } = await params;
38 const user = getUserByDisplayId(accountCode, displayId);
39 if (!user) notFound();
40
41 const initialValues = toUpdateValues(user);
42
43 return (
44 <>
45 <AppSidebar />
46 <SidebarInset>
47 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
48 <div className="flex items-center gap-2 px-4">
49 <SidebarTrigger className="-ml-1" />
50 <Separator
51 orientation="vertical"
52 className="mr-2 data-[orientation=vertical]:h-4"
53 />
54 <Breadcrumb>
55 <BreadcrumbList>
56 <BreadcrumbItem className="hidden md:block">
57 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink>
58 </BreadcrumbItem>
59 <BreadcrumbSeparator className="hidden md:block" />
60 <BreadcrumbItem>
61 <BreadcrumbPage>ユーザ情報編集({displayId})</BreadcrumbPage>
62 </BreadcrumbItem>
63 </BreadcrumbList>
64 </Breadcrumb>
65 </div>
66 </header>
67
68 <div className="max-w-xl p-4 pt-0">
69 <Client
70 initialValues={initialValues}
71 roleOptions={mockRoleOptions}
72 accountCode={accountCode}
73 />
74 </div>
75 </SidebarInset>
76 </>
77 );
78}
動作確認のポイント
/users/U00000001/edit
のように、存在するdisplayId
でアクセス(モックの中身で確認)displayId
は読み取り専用で表示される(DB自動採番の表示IDという前提)- 入力エラーは Zodスキーマ に基づき
<FormMessage>
に表示 - 送信すると トースト が出る((protected)/layout.tsx の が全ページで有効)
npm run dev
で動作を確認してみてください。/users/U00000001
にアクセスすると、下図のようになります。バリデーションをクリアすれば、トースト通知が表示されます。
本章の実装で「新規 (4章)」「編集 (5章)」の フォームUIが共通化 できました。次は 一覧ページを作成して、テーブル表示や詳細の見せ方を整えます。
6. 一覧ページ(/users
)
「新規(4章)」「編集(5章)」ができたので、一覧を実装します。 今回は UIのみ・モックのみ の前提で、次を満たす作りにします。
- 絞り込み :キーワード(氏名/メール)、ロール、状態(有効/無効)
- 並び :
displayId
昇順(簡易固定) - 操作 :各行から 編集(=詳細兼用)へ遷移
- 論理削除の非表示 :
deletedAt
があるユーザは一覧に出さない
新規作成は
/users/new
、編集(詳細兼用)は /users/[displayId]
へリンクします。事前追加(Shadcn/ui
の Table
と Badge
)
一覧ページは
Shadcn/ui
のデータテーブルを利用します。あわせて、Badge
も利用するので、インストールします。zsh
1npx shadcn@latest add table badge
2npm install @tanstack/react-table
データテーブルを利用するので、下記のような構成になります。
txt
1src/
2└─ app/
3 └─ (protected)/
4 └─ users/
5 ├─ columns.tsx # 列定義(編集リンク含む)
6 ├─ data-table.tsx # Data table本体(検索・フィルタ・ページング)
7 └─ page.tsx # SSRでモック取得→DataTableへ受け渡し
下図のようなUIを作成していきます。

列定義ファイル:columns.tsx
の作成
テーブルに表示するカラムについて定義します。
tsx
1// src/app/(protected)/users/columns.tsx
2"use client";
3
4import Link from "next/link";
5import type { ColumnDef } from "@tanstack/react-table";
6import { Badge } from "@/components/ui/badge";
7import { Button } from "@/components/ui/button";
8import {
9 Tooltip,
10 TooltipContent,
11 TooltipTrigger,
12} from "@/components/ui/tooltip";
13import { SquarePen } from "lucide-react";
14import type { MockUser } from "@/lib/users/mock";
15import type { RoleCode } from "@/lib/users/schema";
16
17/** 一覧のロール表示ラベル */
18export const roleLabel: Record<RoleCode, string> = {
19 ADMIN: "管理者",
20 EDITOR: "編集者",
21 VIEWER: "閲覧者",
22};
23
24/** DataTable 側のフィルタ値型(列の filterFn と揃える) */
25export type StatusFilter = "ALL" | "ACTIVE" | "INACTIVE";
26export type RoleFilter = "ALL" | RoleCode;
27
28export const columns: ColumnDef<MockUser>[] = [
29 {
30 id: "actions",
31 header: "操作",
32 enableResizing: false,
33 size: 40,
34 enableSorting: false,
35 cell: ({ row }) => (
36 <Tooltip>
37 <TooltipTrigger asChild>
38 <Button
39 asChild
40 size="icon"
41 variant="outline"
42 data-testid={`edit-${row.original.displayId}`}
43 className="size-8 cursor-pointer"
44 >
45 <Link href={`/users/${row.original.displayId}`}>
46 <SquarePen />
47 </Link>
48 </Button>
49 </TooltipTrigger>
50 <TooltipContent>
51 <p>参照・編集</p>
52 </TooltipContent>
53 </Tooltip>
54 ),
55 },
56
57 {
58 accessorKey: "displayId",
59 header: "表示ID",
60 cell: ({ row }) => (
61 <span className="font-mono">{row.original.displayId}</span>
62 ),
63 },
64 { accessorKey: "name", header: "氏名" },
65 { accessorKey: "email", header: "メール" },
66 {
67 accessorKey: "roleCode",
68 header: "ロール",
69 enableResizing: false,
70 size: 56,
71 cell: ({ row }) => (
72 <Badge variant="secondary">{roleLabel[row.original.roleCode]}</Badge>
73 ),
74 // ロールフィルタ("ALL"なら素通し)
75 filterFn: (row, _id, value: RoleFilter) =>
76 value === "ALL" ? true : row.original.roleCode === value,
77 },
78
79 {
80 accessorKey: "isActive",
81 header: "状態",
82 enableResizing: false,
83 size: 50,
84 cell: ({ row }) =>
85 row.original.isActive ? (
86 <Badge data-testid="badge-active">有効</Badge>
87 ) : (
88 <Badge variant="outline" data-testid="badge-inactive">
89 無効
90 </Badge>
91 ),
92 // 状態フィルタ
93 filterFn: (row, _id, value: StatusFilter) =>
94 value === "ALL"
95 ? true
96 : value === "ACTIVE"
97 ? row.original.isActive
98 : !row.original.isActive,
99 },
100 // 検索用の hidden 列(displayId/name/email を結合)
101 {
102 id: "q",
103 accessorFn: (row) =>
104 `${row.displayId} ${row.name} ${row.email}`.toLowerCase(),
105 enableHiding: true,
106 enableSorting: false,
107 enableResizing: false,
108 size: 0,
109 header: () => null,
110 cell: () => null,
111 },
112];
💡ポイント解説(columns.tsx
)
1) 列幅の“実質固定”は size
だけでは足りない
TanStack Table の
これにより
size
はヒント値です。テーブル側で table-fixed
を付け、各ヘッダ/セルに style={{ width: column.getSize() }}
を当てることで効きます。これにより
actions / roleCode / isActive
のような狭い列を安定させられます。tsx
1/* (抜粋)DataTable 側:レンダリングに幅をバインド */
2<Table className="table-fixed w-full">
3 <TableHeader>
4 {table.getHeaderGroups().map((hg) => (
5 <TableRow key={hg.id}>
6 {hg.headers.map((header) => (
7 <TableHead
8 key={header.id}
9 style={{ width: header.getSize() }} // ← 重要
10 >
11 {header.isPlaceholder
12 ? null
13 : flexRender(header.column.columnDef.header, header.getContext())}
14 </TableHead>
15 ))}
16 </TableRow>
17 ))}
18 </TableHeader>
19
20 <TableBody>
21 {table.getRowModel().rows.map((row) => (
22 <TableRow key={row.id}>
23 {row.getVisibleCells().map((cell) => (
24 <TableCell
25 key={cell.id}
26 style={{ width: cell.column.getSize() }} // ← 重要
27 >
28 {flexRender(cell.column.columnDef.cell, cell.getContext())}
29 </TableCell>
30 ))}
31 </TableRow>
32 ))}
33 </TableBody>
34</Table>
2) 操作列(actions)は最小で使いたい
enableResizing: false
と小さめの size
を指定し、上記の“幅バインド”と併用します。また、仕様変更に合わせて 編集=詳細統合 のためリンク先は
/users/[displayId]
に統一します。tsx
1// actions 列(抜粋)
2{
3 id: "actions",
4 header: "操作",
5 enableResizing: false,
6 size: 40, // ヒント値(幅バインドと合わせて効かせる)
7 enableSorting: false,
8 cell: ({ row }) => (
9 <Tooltip>
10 <TooltipTrigger asChild>
11 <Button
12 asChild
13 size="icon"
14 variant="outline"
15 data-testid={`edit-${row.original.displayId}`}
16 className="size-8 cursor-pointer"
17 aria-label="参照・編集"
18 >
19 <Link href={`/users/${row.original.displayId}`}>
20 <SquarePen />
21 </Link>
22 </Button>
23 </TooltipTrigger>
24 <TooltipContent>
25 <p>参照・編集</p>
26 </TooltipContent>
27 </Tooltip>
28 ),
29}
3) ロール表示は型安全に:RoleCode → 日本語ラベル
のマップ
値の“正”は
バリデーション/選択肢/型の“単一出所”を崩さずに表記だけ変えられます。
z.enum(ROLE_CODES)
(= RoleCode
)です。表示ラベルだけをマップで持つと、バリデーション/選択肢/型の“単一出所”を崩さずに表記だけ変えられます。
tsx
1// ロールの表示ラベル(型安全)
2export const roleLabel: Record<RoleCode, string> = {
3 ADMIN: "管理者",
4 EDITOR: "編集者",
5 VIEWER: "閲覧者",
6};
7
8// セル(抜粋)
9{
10 accessorKey: "roleCode",
11 header: "ロール",
12 enableResizing: false,
13 size: 56,
14 cell: ({ row }) => (
15 <Badge variant="secondary">{roleLabel[row.original.roleCode]}</Badge>
16 ),
17 // ...
18}
4) フィルタ型を列と UI 間で共有
DataTable 側の Select と列の
filterFn
の値型を揃えるため、StatusFilter
/ RoleFilter
をエクスポート して共有します。"ALL"
は素通し、個別値は一致チェックというシンプルな実装にします。tsx
1// 共有フィルタ型
2export type StatusFilter = "ALL" | "ACTIVE" | "INACTIVE";
3export type RoleFilter = "ALL" | RoleCode;
4
5// 列側の filterFn(抜粋)
6{
7 accessorKey: "roleCode",
8 // ...
9 filterFn: (row, _id, value: RoleFilter) =>
10 value === "ALL" ? true : row.original.roleCode === value,
11},
12{
13 accessorKey: "isActive",
14 // ...
15 filterFn: (row, _id, value: StatusFilter) =>
16 value === "ALL"
17 ? true
18 : value === "ACTIVE"
19 ? row.original.isActive
20 : !row.original.isActive,
21},
5) フリーテキスト検索は “隠し列” で一元化
displayId / name / email
を結合して小文字化した 隠し列 q
を用意。フロントの検索キーワードはこの列だけを対象にすれば、列追加時も影響が小さいです。
tsx
1// 検索用の hidden 列
2{
3 id: "q",
4 accessorFn: (row) =>
5 `${row.displayId} ${row.name} ${row.email}`.toLowerCase(),
6 enableHiding: true,
7 enableSorting: false,
8 enableResizing: false,
9 size: 0,
10 header: () => null,
11 cell: () => null,
12}
6) 小まとめ
- レンダリング側で幅をバインド(
table-fixed
+style={{ width: getSize() }}
)。 - 固定したい列は
enableResizing: false
と小さめsize
を宣言。 - フィルタは型を共有し、
"ALL"
は素通し。 - テキスト検索は隠し列で一元化。
- 編集リンクは
/users/[displayId]/edit
に統一(詳細=編集)。
データテーブル本体:data-table.tsx
の作成
カラム定義のつぎは、データテーブル本体を作成していきます。下記の通りです。
tsx
1// src/app/(protected)/users/data-table.tsx
2"use client";
3
4import * as React from "react";
5import Link from "next/link";
6import type { ColumnDef, SortingState } from "@tanstack/react-table";
7import {
8 flexRender,
9 getCoreRowModel,
10 getPaginationRowModel,
11 getSortedRowModel,
12 useReactTable,
13} from "@tanstack/react-table";
14import { Input } from "@/components/ui/input";
15import {
16 Select,
17 SelectContent,
18 SelectItem,
19 SelectTrigger,
20 SelectValue,
21} from "@/components/ui/select";
22import {
23 Table,
24 TableBody,
25 TableCell,
26 TableHead,
27 TableHeader,
28 TableRow,
29} from "@/components/ui/table";
30import { Button } from "@/components/ui/button";
31
32import type { MockUser, RoleOption } from "@/lib/users/mock";
33import type { RoleFilter, StatusFilter } from "./columns";
34
35type Props<TData> = {
36 columns: ColumnDef<TData, unknown>[];
37 data: TData[];
38 roleOptions: RoleOption[];
39};
40
41export default function DataTable<TData extends MockUser>({
42 columns,
43 data,
44 roleOptions,
45}: Props<TData>) {
46 // 検索/フィルタUIの状態
47 const [q, setQ] = React.useState("");
48 const [role, setRole] = React.useState<RoleFilter>("ALL");
49 const [status, setStatus] = React.useState<StatusFilter>("ALL");
50 const [sorting, setSorting] = React.useState<SortingState>([
51 { id: "displayId", desc: false },
52 ]);
53
54 // ❶ ここで前処理フィルタ(文字列検索・ロール・状態)
55 const filteredData = React.useMemo(() => {
56 const needle = q.trim().toLowerCase();
57 return (data as MockUser[]).filter((u) => {
58 const passQ =
59 !needle ||
60 `${u.displayId} ${u.name} ${u.email}`.toLowerCase().includes(needle);
61
62 const passRole = role === "ALL" || u.roleCode === role;
63
64 const passStatus =
65 status === "ALL" ||
66 (status === "ACTIVE" ? u.isActive === true : u.isActive === false);
67
68 return passQ && passRole && passStatus;
69 }) as unknown as TData[];
70 }, [data, q, role, status]);
71
72 const table = useReactTable({
73 data: filteredData, // ❷ フィルタ済みデータを渡す
74 columns,
75 state: { sorting }, // ❸ フィルタ関連の state は渡さない
76 onSortingChange: setSorting,
77 getCoreRowModel: getCoreRowModel(),
78 getSortedRowModel: getSortedRowModel(),
79 getPaginationRowModel: getPaginationRowModel(),
80 initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
81 });
82
83 const filteredCount = filteredData.length;
84
85 return (
86 <div className="space-y-3">
87 {/* 検索/フィルタ */}
88 <div className="flex flex-wrap items-center gap-3">
89 <Input
90 name="filter-q"
91 data-testid="filter-q"
92 value={q}
93 onChange={(e) => setQ(e.target.value)}
94 placeholder="氏名・メール・表示IDで検索"
95 className="w-[192px] basis-full text-sm md:basis-auto"
96 aria-label="検索キーワード"
97 />
98
99 <Select
100 value={role}
101 onValueChange={(v) => setRole(v as RoleFilter)}
102 name="filter-role"
103 >
104 <SelectTrigger className="w-auto" data-testid="filter-role">
105 <SelectValue placeholder="ロール" />
106 </SelectTrigger>
107 <SelectContent className="text-xs">
108 <SelectItem value="ALL">
109 <span className="text-muted-foreground">すべてのロール</span>
110 </SelectItem>
111 {roleOptions.map((o) => (
112 <SelectItem key={o.value} value={o.value}>
113 {o.label}
114 </SelectItem>
115 ))}
116 </SelectContent>
117 </Select>
118
119 <Select
120 value={status}
121 onValueChange={(v) => setStatus(v as StatusFilter)}
122 name="filter-status"
123 >
124 <SelectTrigger className="w-auto" data-testid="filter-status">
125 <SelectValue placeholder="状態" />
126 </SelectTrigger>
127 <SelectContent>
128 <SelectItem value="ALL">
129 <span className="text-muted-foreground">すべての状態</span>
130 </SelectItem>
131 <SelectItem value="ACTIVE">有効のみ</SelectItem>
132 <SelectItem value="INACTIVE">無効のみ</SelectItem>
133 </SelectContent>
134 </Select>
135 </div>
136
137 <div className="flex items-center justify-between">
138 <div className="text-sm" data-testid="count">
139 表示件数: {filteredCount} 件
140 </div>
141 <Button asChild>
142 <Link href="/users/new">新規登録</Link>
143 </Button>
144 </div>
145
146 {/* テーブル */}
147 <div className="overflow-x-auto rounded-md border pb-1">
148 <Table data-testid="users-table" className="w-full">
149 <TableHeader className="bg-muted/50 text-xs">
150 {table.getHeaderGroups().map((hg) => (
151 <TableRow key={hg.id}>
152 {hg.headers.map((header) => (
153 <TableHead
154 key={header.id}
155 style={{ width: header.column.getSize() }}
156 >
157 {header.isPlaceholder
158 ? null
159 : flexRender(
160 header.column.columnDef.header,
161 header.getContext(),
162 )}
163 </TableHead>
164 ))}
165 </TableRow>
166 ))}
167 </TableHeader>
168 <TableBody>
169 {table.getRowModel().rows.length ? (
170 table.getRowModel().rows.map((row) => (
171 <TableRow
172 key={row.id}
173 data-testid={`row-${(row.original as MockUser).displayId}`}
174 >
175 {row.getVisibleCells().map((cell) => (
176 <TableCell
177 key={cell.id}
178 style={{ width: cell.column.getSize() }}
179 >
180 {flexRender(
181 cell.column.columnDef.cell,
182 cell.getContext(),
183 )}
184 </TableCell>
185 ))}
186 </TableRow>
187 ))
188 ) : (
189 <TableRow>
190 <TableCell
191 colSpan={columns.length}
192 className="text-muted-foreground py-10 text-center text-sm"
193 >
194 条件に一致するユーザが見つかりませんでした。
195 </TableCell>
196 </TableRow>
197 )}
198 </TableBody>
199 </Table>
200 </div>
201
202 {/* ページング */}
203 <div className="flex items-center justify-end gap-2">
204 <span className="text-muted-foreground text-sm">
205 Page {table.getState().pagination.pageIndex + 1} /{" "}
206 {table.getPageCount() || 1}
207 </span>
208 <Button
209 variant="outline"
210 size="sm"
211 onClick={() => table.previousPage()}
212 disabled={!table.getCanPreviousPage()}
213 data-testid="page-prev"
214 className="cursor-pointer"
215 >
216 前へ
217 </Button>
218 <Button
219 variant="outline"
220 size="sm"
221 onClick={() => table.nextPage()}
222 disabled={!table.getCanNextPage()}
223 data-testid="page-next"
224 className="cursor-pointer"
225 >
226 次へ
227 </Button>
228 </div>
229 </div>
230 );
231}
💡ポイント解説(data-table.tsx
)
本コンポーネントは 「表示まわりは @tanstack/react-table、検索/フィルタは React のローカル状態」 に分離した構成です。公式サンプルの設計思想を踏まえつつ、shadcn/ui のテーブルと気持ちよく噛み合わせています。
1) 事前フィルタ(useMemo)で “表” に渡すデータを確定
q
(全文検索)、role
(ロール)、status
(有効/無効)を React の状態 で管理し、
useMemo
でfilteredData
を作ってからuseReactTable
に渡しています(コメントの❶/❷)。- こうすると 列フィルタ(columnFilters) を使わずに済み、
- Portal を使う
<Select>
とフィルタ状態の 相互再レンダリング のややこしさを回避 - 検索文字列の 正規化(trim + lower-case) を一括で行える
- Portal を使う
- 規模が増えても、API 側で同条件を WHERE に落としてくればスケールできます(UIでは同じ条件ロジックを使うだけ)。
2) テーブルの “責務” はソート/ページングのみ
useReactTable
のstate
には sorting だけ を渡し、
getSortedRowModel
とgetPaginationRowModel
を有効化。- つまり「フィルタは外で済ませ、テーブルは表示ロジックに専念」という役割分担です。
初期ソートはdisplayId
昇順([{ id: "displayId", desc: false }]
)。
3) 列幅の扱い(手動サイズ指定との合わせ技)
- shadcn/ui の
<TableHead>
/<TableCell>
にstyle={{ width: column.getSize() }}
を当てています。
これで 列定義側のsize
(例:actions
/roleCode
/isActive
)が ヘッダ/セルの両方 に反映され、
固定幅列 + 可変列 の共存が安定します。 - 横スクロールはテーブルラッパに
overflow-x-auto
を付与。
サイドレイアウトのコンテナ側はmin-w-0
をつけて「コンテンツが子の最小幅で突っ張らない」ようにしておくのがコツ(本件はSidebarInset
側で対応済み)。
4) 検索/フィルタ UI の値域と型の揃え方
- ロール:
RoleFilter = "ALL" | RoleCode
("ALL"
は素通しの番人)。 - 状態:
StatusFilter = "ALL" | "ACTIVE" | "INACTIVE"
("ALL"
は素通し)。 - UI のセレクトは 文字列 を扱いますが、
useMemo
内の比較では
role === "ALL" ? true : u.roleCode === role
のように 明示的に分岐 して安全に判定しています。
5) レイアウトとレスポンシブの小ワザ
- ヘッダは
bg-muted/50 text-xs
で視認性を上げ、
インプットはbasis-full → md:basis-auto
でモバイルでは 1段落ち、MD 以上で 横並び。 - 件数表示(
表示件数:{filteredCount} 件
)は 前処理結果の長さ を直接使うため、
ページングやソートに関係なく “いまの絞り込み” に対して正確な件数が常に出ます。
6) ページング操作は Table API 直呼び
table.previousPage()
/table.nextPage()
をそのままボタンに紐づけ。
getCanPreviousPage()
/getCanNextPage()
で 無効化制御 を行っています。- 現在ページは
table.getState().pagination.pageIndex + 1
を直接参照(0始まり対策)。
7) ジェネリック構成(再利用の下地)
DataTable<TData extends MockUser>
としておき、
列はColumnDef<TData>
、データはTData[]
。- 「ユーザ一覧」という文脈では
MockUser
固定で運用しつつ、
同パターンで他エンティティに横展開 する際の型安全を残しています。
8) アクセシビリティ/テスト
- 入力やトリガには
aria-label
を付与。 - E2E 想定で各所に
data-testid
を振っています(filter-*
,users-table
,row-<displayId>
など)。
レンダリングの安定性向上と 選択子の長寿命化 に効きます。
9) よくあるハマりどころと回避策
- セレクト操作で UI が固まる/無限再描画:
列フィルタ(columnFilters
)と外部状態更新が絡むと再レンダのトリガが増えがち。
→ 本稿のように 前処理フィルタ に寄せると安定します。 - 表の横幅が親幅を突き破る:
ラッパにoverflow-x-auto
、親レイアウト(page.tsx
)にmin-w-0
を忘れずに。 - 固定幅列が効かない:
style={{ width: column.getSize() }}
を ヘッダ/セルの両方 に当てる(どちらか片方だけだと崩れやすい)。
まとめ:
フィルタは前処理、テーブルは表示ロジック。これだけで再レンダや相互依存の複雑さがグッと下がり、
shadcn/ui の素直なマークアップと @tanstack/react-table の相性が一気に良くなります。
フィルタは前処理、テーブルは表示ロジック。これだけで再レンダや相互依存の複雑さがグッと下がり、
shadcn/ui の素直なマークアップと @tanstack/react-table の相性が一気に良くなります。
ページファイル:users/page.tsx
の作成
最後にページファイルを作成します。下記の通りです。
tsx
1// src/app/(protected)/users/page.tsx
2import type { Metadata } from "next";
3import { AppSidebar } from "@/components/sidebar/app-sidebar";
4import { SidebarInset, SidebarTrigger } from "@/components/ui/sidebar";
5import {
6 Breadcrumb,
7 BreadcrumbItem,
8 BreadcrumbLink,
9 BreadcrumbList,
10 BreadcrumbPage,
11 BreadcrumbSeparator,
12} from "@/components/ui/breadcrumb";
13import { Separator } from "@/components/ui/separator";
14
15import DataTable from "./data-table";
16import { columns } from "./columns";
17
18import {
19 CURRENT_ACCOUNT_CODE,
20 getUsersByAccount,
21 mockRoleOptions,
22} from "@/lib/users/mock";
23
24export const metadata: Metadata = {
25 title: "ユーザ一覧 | 管理画面レイアウト【DELOGs】",
26 description:
27 "Data table(shadcn/ui + @tanstack/react-table)でユーザ一覧を表示",
28};
29
30export default async function Page() {
31 // UIのみ:ログイン中アカウント配下のユーザをモックから取得
32 const accountCode = CURRENT_ACCOUNT_CODE;
33 const all = getUsersByAccount(accountCode);
34 const users = all.filter((u) => !u.deletedAt); // 論理削除は一覧非表示
35
36 return (
37 <>
38 <AppSidebar />
39 <SidebarInset className="min-w-0">
40 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
41 <div className="flex items-center gap-2 px-4">
42 <SidebarTrigger className="-ml-1" />
43 <Separator
44 orientation="vertical"
45 className="mr-2 data-[orientation=vertical]:h-4"
46 />
47 <Breadcrumb>
48 <BreadcrumbList>
49 <BreadcrumbItem className="hidden md:block">
50 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink>
51 </BreadcrumbItem>
52 <BreadcrumbSeparator className="hidden md:block" />
53 <BreadcrumbItem>
54 <BreadcrumbPage>ユーザ一覧</BreadcrumbPage>
55 </BreadcrumbItem>
56 </BreadcrumbList>
57 </Breadcrumb>
58 </div>
59 </header>
60
61 <div className="max-w-full p-4 pt-0">
62 <DataTable
63 columns={columns}
64 data={users}
65 roleOptions={mockRoleOptions}
66 />
67 </div>
68 </SidebarInset>
69 </>
70 );
71}
💡ポイント解説(users/page.tsx
)
Server Component で“ページ殻”を担当
メタデータやパンくず、サイドバー構成など レイアウトと初期データ取得 をこのページが担い、
実際の表のインタラクションは Client Component(
実際の表のインタラクションは Client Component(
data-table.tsx
)へ委譲します。min-w-0
で横幅の突っ張りを解消
<SidebarInset className="min-w-0">
を付けて“親が子要素の最小幅に引っ張られる”現象を防止。これがないと、データテーブルの縮小が上手くいかないことがあります。これにより、
overflow-x-auto
なテーブルでも 横スクロールの挙動が安定 します。Client への受け渡しは最小限の Props
DataTable
に渡すのは columns / data / roleOptions
のみ。検索・フィルタ・ページングの状態は クライアント内で完結 するため、水平方向のデータフローが明快です。
将来の API 置換ポイントが明確
getUsersByAccount
を DB/REST/GraphQL へ置換し、deletedAt
除外とアカウント制約は サーバ側で強制 に切り替えるだけで UI は不変です。データ量が増えたら サーバページング(クエリパラメータと
fetch
)へ移行可能。リンク先はルーティングに合わせて
columns.tsx
の編集リンクは、あなたの構成に合わせてhref="/users/[displayId]/edit"
など 正しいパス にそろえてください(参照専用=編集兼用の仕様)。余談:async Page でも OK
現状は同期処理ですが、将来
await fetch()
に差し替える前提で async
な Page 宣言にしておくのは自然です。7. まとめと次回予告
本記事では「UIだけでユーザ管理を一通り回せる最小構成」を、重複の少ない共通基盤で組み上げました。
できたこと(要点)
- 型とバリデーションの単一出所
Zod v4 のスキーマ(userCreateSchema
/userUpdateSchema
)を真実の出所にし、z.infer
で型を自動派生。
ロールはz.enum(ROLE_CODES)
で 値・型・UI(セレクト)を完全同期。 - 共通フォーム
<UserForm />
1コンポーネントをmode="create" | "edit"
で出し分け。
Shadcn/ui の<Form>
が RHF コンテキストを供給するため、最小構成では自前のFormProvider
は不要。
パスワードの表示/非表示トグルやdata-testid
など、実務の小ワザも添えました。 - ページ構造(SSR + クライアント軽量ラッパー)
/(protected)/users/new
と/(protected)/users/[displayId]/edit
を SSR で構築し、
提交処理やトーストなどのインタラクションは Client に委譲。
Toaster
は/(protected)/layout.tsx
に配置して 遷移をまたいでも通知が持続。 - 一覧(Data table)
Shadcn/ui × TanStack Table v8 を採用。
クライアント側で 検索(displayId/name/email)・ロール・状態 をuseMemo
前処理 → 表示のパフォーマンスを確保。
見た目のハマりどころである横幅問題は、<SidebarInset className="min-w-0">
で解消。 - マルチテナントの癖付け
モックでも常にaccountCode
を引数に取り、他テナントに触れない前提でユーティリティを設計。
編集画面は詳細兼用にし、AlertDialog
経由の論理削除ボタンも導線化。
次回予告:サイドバーと現在ページの同期
次回は サイドバーのメニューと“参照中ページ”の同期 を実装します。具体的には——
- 現在地ハイライト:
usePathname
/ (もしくは)useSelectedLayoutSegments
でルート情報を取得し、
対応するメニューをaria-current="page"
付きで強調表示(アクセシブルに)。 - グループの自動展開:ネストされたメニューでは、現在地に応じて親グループを開く。
そのためにメニュー定義(ツリー)を1箇所に集約し、「パス → ノード」を逆引きできるようにします。 - 動的セグメント対応:
/users/[displayId]/edit
のような動的ルートでも、
「配下の“ユーザ管理”グループをアクティブ扱い」にするマッピングを用意。 - 余裕があれば、パンくずの自動生成(メニュー定義からの派生)にも触れます。
今回の共通基盤の上に“どのページでも迷わないナビゲーション”を載せて、運用体験をもう一段整えます。
参考文献
- Next.js
- File Conventions: page(サーバーコンポーネントと
params
の扱い)
https://nextjs.org/docs/app/api-reference/file-conventions/page
- File Conventions: page(サーバーコンポーネントと
- shadcn/ui
- Data table(TanStack Table v8 連携)
https://ui.shadcn.com/docs/components/data-table - Form / Select / Switch / Table / Alert Dialog / Sonner(本記事で使用)
https://ui.shadcn.com/docs/components/form
https://ui.shadcn.com/docs/components/select
https://ui.shadcn.com/docs/components/switch
https://ui.shadcn.com/docs/components/table
https://ui.shadcn.com/docs/components/alert-dialog
https://ui.shadcn.com/docs/components/sonner
- Data table(TanStack Table v8 連携)
- TanStack Table v8
- Column Sizing / Sorting / Pagination
https://tanstack.com/table/v8/docs/guide/column-sizing
https://tanstack.com/table/v8/docs/guide/sorting
https://tanstack.com/table/v8/docs/guide/pagination
- Column Sizing / Sorting / Pagination
- React Hook Form
- API(Resolver・
useForm
・FormProvider
)
https://react-hook-form.com/docs
- API(Resolver・
- Zod v4
- スキーマ定義・
z.infer
・z.email()
https://zod.dev/
- スキーマ定義・
- (参考)本シリーズの前提記事
- Prisma × PostgreSQLで始めるユーザー・ロール管理
https://delogs.jp/next-js/backend/prisma-user-role - Shadcn/uiで作るログイン後の管理画面レイアウト
https://delogs.jp/next-js/shadcn-ui/dashboard-layout
- Prisma × PostgreSQLで始めるユーザー・ロール管理
Githubリポジトリ
この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更
管理画面に「プロフィール」ページを追加し、ユーザ自身が情報やパスワードを更新できるUIを作成
2025/8/22公開
![[管理画面フォーマット制作編 #5] ユーザープロフィールUI ─ 情報確認・編集・パスワード変更のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fuser-profile-ui%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期
Next.js App Router + shadcn/ui のサイドバーで「いま見ているページ」を正しくハイライト
2025/8/19公開
![[管理画面フォーマット制作編 #4] サイドバーのメニューと参照中ページの同期のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fsidebar-active-sync%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #2] Shadcn/uiで作るログイン後の管理画面レイアウト
Shadcn/uiで簡単に管理画面UIを構築。共通ヘッダ、サイドメニューなどの基本レイアウトを作成
2025/8/8公開
![[管理画面フォーマット制作編 #2] Shadcn/uiで作るログイン後の管理画面レイアウトのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fdashboard-layout%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット制作編 #1] Shadcn/uiで作るログイン画面
Shadcn/uiを利用してログインを画面作成。UIのほかZodによるバリデーションなどを実践
2025/7/24公開
![[管理画面フォーマット制作編 #1] Shadcn/uiで作るログイン画面のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Flogin-form%2Fhero-thumbnail.jpg&w=1200&q=75)
Next.js+shadcn/uiのインストールと基本動作のまとめ
開発環境(ローカルPC)にNext.js 15とshadcn/uiをインストールして、基本の動作を確認
2025/6/20公開
