DELOGs
[管理画面フォーマット制作編 #3] Shadcn/uiで作るユーザ管理UI ─ 詳細・新規・編集フォーム実装

管理画面フォーマット制作編 #3
Shadcn/uiで作るユーザ管理UI ─ 詳細・新規・編集フォーム実装

管理者向けのユーザ詳細表示・新規登録・編集画面をShadcn/uiとReact Hook Form、Zodを組み合わせて実装

初回公開日

最終更新日

0. はじめに

本記事では、管理者向けのユーザ管理UIを構築します。対象となるページは次の4つです。
  • 新規登録ページ /users/new
  • 詳細・編集ページ /users/[displayId]
  • 一覧ページ /users
個別にゼロから作るのではなく、 共通フォーム基盤 (Shadcn/ui + React Hook Form + Zod)を最初に用意し、それを各ページへ適用する方針で進めます。共通化により、UIとバリデーションの重複を避け、保守性と実装速度を高めます。

本記事の前提(必読の過去記事)

💡以降は、上記のテーブル設計と管理画面レイアウトを前提に、ユーザ管理ページ群を実装します。
displayIdDB側で自動採番 するため、新規作成フォームでは入力不要(非表示)、編集時は読み取り専用で表示します。

技術スタック

Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング
Zod4.xスキーマ定義と実行時バリデーション
次章では、全体のページ構成とデータフローを整理し、共通フォーム基盤の要件を明確にします。

1. 実装の全体像とページ構成

今回構築するユーザ管理機能は、一覧/新規作成/詳細・編集 という管理画面の基本的な遷移パターンを備えます。
ただし実装は、以下の順序で進めます。
  1. 共通フォーム基盤の作成
    • /users/new/users/edit で共通利用する UserForm コンポーネントを作成
    • フィールド構成(name/email/password/role/isActive/account)とZodスキーマを統一
  2. 新規登録ページ /users/new
    • 空の初期値を設定し、UserForm を適用
    • 送信後は一覧または詳細ページへ遷移(今回は仮遷移)
  3. 詳細・編集ページ /users/[displayId]
    • 仮データを初期値として適用
    • displayIdは読み取り専用で表示
  4. 一覧ページ /users
    • 仮データをテーブル表示
    • 新規作成・詳細/編集へのリンクを設置

データフロー(UI実装前提)

  • データ取得
    • すべてのページで仮データを利用し、API接続やDBアクセスは行わない
  • データ送信
    • 新規登録/編集フォームは送信イベントだけ実装(実データ保存は行わない)
  • ID管理
    • displayId は仮データ内で用意(新規登録時は非表示、編集・詳細では表示専用)
この構成により、フォームUIとバリデーションロジックを共通化しつつ、ページ単位の役割分担を明確にできます。
次章では、この基盤となる共通フォームの作成から着手します。

2. 共通フォーム基盤の作成

最初に「新規」「編集」で共有する“型・バリデーション・UI部品”を用意します。ポイントは次の3つです。
  • Zodで 単一のスキーマ を定義(型は z.infer で自動生成)
  • Shadcn/ui の <Form> を使って RHF コンテキストを供給 (最小構成では自前の FormProvider は不要)
  • 入力部品は 同ファイル内の小さなコンポーネント に分割(必要になったら別ファイルへ切り出し)
管理画面は登録・編集フォームが多く、項目も似通います。退屈になりがちな作業を減らすため、共通基盤でできるだけ重複を排除します。
なお、今回はUI記事のため “UIのみ・仮データのみ” で進めます(API呼び出し等は行いません)。

まず補足:z.infer<Form>(RHFコンテキスト)の意味

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 を使えば、スキーマ変更に型も連動します。
<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)→ 共通フォームの順に手を動かしていきます。
続く節で具体的なスキーマ定義と、モックデータ、共通フォームの実装へ進みます。

ディレクトリ構成とインストール(前提確認)

まず用語をひとことだけ:
  • RHFReact 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>;

💡このファイルのポイント

  • バリデーションの数字(長さなど)は定数化しておくと、運用途中の見直しが楽です。
  • userCreateSchemauserUpdateSchema を分離し、「新規では password 必須/編集では扱わない」「編集では displayId を表示専用で保持」という要件をスキーマ側に明示します。これにより、フォームUIはスキーマに従うだけで分岐がシンプルになります。
  • 末尾の UserCreateValues / UserUpdateValuesz.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一覧用に、ログイン中アカウント配下のユーザだけを取得mockUsersfilter実運用では WHERE account_id = ? に相当(サーバ側で強制)
getUserByDisplayId詳細/編集で1件取得mockUsersfind他アカウントのIDはヒットしない(テナント境界)
toUpdateValues編集フォームの初期値へ変換displayId/name/email/roleCode/isActive を抽出フォームでは accountCode を扱わない方針を徹底
markDeleted論理削除の疑似的な処理deletedAtnew Date()をセットする他アカウントのIDはヒットしない(テナント境界)
nextDisplayIdFor新規作成時の 表示ID(U00000001 形式)を擬似採番同一アカウント内の displayId から最大数値を抽出→+1→ゼロ埋め本番はDBのシーケンス/関数で採番(競合対策はDBに委譲)
composeCreatePayloadフォーム値+アカウントで 新規レコード形のモック を作るnextDisplayIdFor()displayId を発番し合体本番では Account.code → accountIdRole.code → roleId をサーバ側で解決
mockRoleOptionsロールの選択肢(セレクト)値集合:ADMIN / EDITOR / VIEWER をラベル化RoleCode 型(z.enum)と同一の値集合で安全に
メモ:roleCodeschema.tsz.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 の <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キャンセル時の挙動(任意。未指定なら何もしない)
initialValuesPartial<UserUpdateValues>編集時の初期値(mode="edit" のときのみ参照)
◯表示/入力ルール(抜粋)
項目新規編集備考
displayId-読み取り専用で表示DB自動採番の表示用ID
name入力入力必須・最大 NAME_MAX 文字
email入力入力必須・z.email
roleCode選択選択RoleCodeADMIN/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つの <UserForm /> を、mode によって新規/編集を出し分ける」。
RHF の useForm はモードごとに別スキーマ(create/update)を使いたいので、内部で CreateForm / EditForm の2コンポーネントに分岐して実装します。これで any を使わずに、onSubmit の引数型もそれぞれ正しく(UserCreateValues / UserUpdateValues)にできます。
  • スモール構成:1ファイル内に「小さなフィールド部品(氏名/メール/ロール/パスワード/有効)」を内包
  • FormProvider は使わず、RHFの control を各 <FormField> に直接渡す(小規模向け)
  • mode="create" のときだけパスワードを表示、mode="edit" のときは displayId を読み取り専用表示
  • roleCodez.enum(ROLE_CODES) による リテラル合併型(型・選択肢・バリデーションの単一出所
また、データの論理削除についても、編集コンポーネントの部分で導線を設置します。このとき、アラートを出したいので、Shadcn/UIAlert 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">氏名&nbsp;*</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">メールアドレス&nbsp;*</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">ロール&nbsp;*</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">パスワード&nbsp;*</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">有効&nbsp;*</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完全に制御valueonValueChange)し、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.tsxServer 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/uiSonnerを利用できるようにします。
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]

編集対象を一意に特定するため、動的セグメント[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/uiTableBadge

一覧ページは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) 小まとめ

  1. レンダリング側で幅をバインド(table-fixed + style={{ width: getSize() }})。
  2. 固定したい列は enableResizing: false と小さめ size を宣言。
  3. フィルタは型を共有し、"ALL" は素通し。
  4. テキスト検索は隠し列で一元化。
  5. 編集リンクは /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 の状態 で管理し、
    useMemofilteredData を作ってから useReactTable に渡しています(コメントの❶/❷)。
  • こうすると 列フィルタ(columnFilters) を使わずに済み、
    • Portal を使う <Select> とフィルタ状態の 相互再レンダリング のややこしさを回避
    • 検索文字列の 正規化(trim + lower-case) を一括で行える
  • 規模が増えても、API 側で同条件を WHERE に落としてくればスケールできます(UIでは同じ条件ロジックを使うだけ)。

2) テーブルの “責務” はソート/ページングのみ

  • useReactTablestate には sorting だけ を渡し、
    getSortedRowModelgetPaginationRowModel を有効化。
  • つまり「フィルタは外で済ませ、テーブルは表示ロジックに専念」という役割分担です。
    初期ソートは 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 の相性が一気に良くなります。

ページファイル: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(data-table.tsx)へ委譲します。

min-w-0 で横幅の突っ張りを解消

<SidebarInset className="min-w-0"> を付けて“親が子要素の最小幅に引っ張られる”現象を防止。これがないと、データテーブルの縮小が上手くいかないことがあります。
これにより、overflow-x-auto なテーブルでも 横スクロールの挙動が安定 します。

Client への受け渡しは最小限の Props

DataTable に渡すのは columns / data / roleOptions のみ。
検索・フィルタ・ページングの状態は クライアント内で完結 するため、水平方向のデータフローが明快です。

将来の API 置換ポイントが明確

getUsersByAccountDB/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]/editSSR で構築し、
    提交処理やトーストなどのインタラクションは 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 のような動的ルートでも、
    「配下の“ユーザ管理”グループをアクティブ扱い」にするマッピングを用意。
  • 余裕があれば、パンくずの自動生成(メニュー定義からの派生)にも触れます。
今回の共通基盤の上に“どのページでも迷わないナビゲーション”を載せて、運用体験をもう一段整えます。

参考文献

Githubリポジトリ

この記事で作成した内容は下記のGithubリポジトリにアップしています。ご参考にどうぞ。
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。