1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js】1:Nデータ 登録パターン実装のベストプラクティスを目指して

1
Last updated at Posted at 2026-03-03

🚀 この記事で学べること

「動くものは作れるようになった。でもこの書き方がベストなのか?」
Next.js のベストプラクティスをキャッチアップして、業務システムで頻出する伝票登録(1:Nデータの登録パターン)へ詰め込みました。

  • ダイアログの枠を超えた、専用画面での複雑なデータ入力フォーム実装
  • useFieldArray を用いた、柔軟な明細行の増減(追加・削除)
  • 「最低1件の明細を必須とする」などの、実務に即したバリデーション
  • 受注ヘッダ + 明細N件 を一括で扱うデータ構造のハンドリング
  • db.transaction を活用した、ヘッダ・明細の不整合を許さない一括更新処理

✅ 基本を振り返りたい方へ
本記事は単体で完結していますが、基本となる「1レコードのCRUD(作成・読取・更新・削除)」について詳しく知りたい方は、以前公開したマスタメンテナンス編もあわせてご覧ください。

📚 参考にさせていただきました

元記事の趣旨を完全に反映できていない可能性があります。
何か誤りなどあれば、お気軽にコメントしてください。
探求中の未熟者なのでご容赦ください🙇


🔎 アプリのイメージ

複数の検索条件とページネーションのある受注一覧
image.png

複雑な1:N関係の受注データ登録画面
image.png

コード入力の支援用カスタムコンボボックス
image.png

実際に触ってみるのが一番イメージが湧くと思います!
デモサイトを用意しました。


1.全体の構成

✅ ディレクトリ構造
(受注管理部分だけを抜粋)

src/
├── app/(protected)/order/
│   ├── _components/
│   │   ├── 受注List.tsx        # 【Client】検索結果の一覧表示
│   │   ├── 受注ListServer.tsx  # 【Server】検索結果の一覧データフェッチ
│   │   ├── 受注Form.tsx        # 【Client】ヘッダ+明細の複雑なフォーム
│   │   └── 受注FormServer.tsx  # 【Server】受注Formのデータフェッチ
│   ├── [id]/
│   │   └── page.tsx           # 【Server】編集画面(Dynamic Routes)
│   ├── new/
│   │   └── page.tsx           # 【Server】新規登録画面
│   ├── actions.ts             # 【Server Action】受注保存(トランザクション処理)
│   └── page.tsx               # 【Server】一覧画面の入り口
│
└── db/
    ├── model/
    │   └── 受注Model.ts        # 【Shared】Zodによる入力検証・型変換ロジック
    ├── repository/
    │   └── 受注Repository.ts   # 【Server Only】Drizzleを使用した純粋なDB操作
    └── schema.ts              # 【Shared】DBテーブル定義(Drizzle Schema)

💡 この構成のポイント

(1) 受注Form.tsx の共通化:

  • 新規登録 (new) でも編集 ([id]) でも、中身の複雑な入力ロジックは同じコンポーネントを使い回せる設計

(2) Dynamic Routes ([id]):

  • マスタメンテの「ダイアログ」とは違い、今回は専用ページ
  • URLにIDが含まれることで、ブラウザのリロードにも強く、URL共有もできる実務スタイル

(3) new フォルダの独立:

  • [id] と分けることで、パラメーターが「IDなのか、"new"という文字列なのか」を判定する面倒なロジックを排除

(4) データフロー

  • DBからのデータ取得はServer Componentで行う
    (データフェッチ・コロケーション用 受注ListServer.tsx → 受注Repository など)
  • Server Componentで取得したデータを、初期値(initialData)としてClient Componentのフォームに渡す

🔤 デモアプリのコード


2.受注一覧表示の実装

✅ 検索・ページネーション

受注一覧では、商品名などのキーワードに加え、業務で多用される「期間検索」もサポート。これら全ての条件を 「URLのクエリパラメータ(?q=xxx&startDate=...&page=1)」 に集約して管理している。

src/app/(protected)/order/_components/受注List.tsx
const searchParams = useSearchParams();

const handleSearch = (e: React.SyntheticEvent<HTMLFormElement>) => {
  e.preventDefault();
  const formData = new FormData(e.currentTarget);
  const params = new URLSearchParams(searchParams.toString());

  const q = formData.get("q") as string;
  const startDate = formData.get("startDate") as string;
  const endDate = formData.get("endDate") as string;

  if (q) params.set("q", q);
  else params.delete("q");
  if (startDate) params.set("startDate", startDate);
  else params.delete("startDate");
  if (endDate) params.set("endDate", endDate);
  else params.delete("endDate");

  // 新しい条件で検索する際は、必ず1ページ目に戻すのが鉄則
  params.set("page", "1");

  startTransition(() => router.push(`?${params.toString()}`));
};

複数項目の検索条件も、やることは同じ

  1. フォームの入力を FormData で一括取得
  2. URLSearchParams に全ての条件をセット
  3. router.push で自分自身のURLを書き換える

これだけで、「検索条件を保持したままのリロード」や「検索結果のURL共有」が可能な、堅牢な一覧画面が完成します。

💡 なぜ検索条件をクエリパラメータに統一するのか?

  • Reactの useState だけで管理すると、ブラウザの「戻る・進む」が機能せず、検索結果のURL共有もできなくなるから
  • 登録画面から一覧へ戻った際、 「さっきまで絞り込んでいた検索条件が消えてしまう」 というユーザーのストレスを完全に防ぐため

ページの入り口(page.tsx)でURLパラメータを非同期(Promise)で読み取り、最新の条件でDBからデータを取得する「データの往復」の仕組みをここでもそのまま活用している。


✅ 明細の編集アイコンクリック時

一覧から特定のデータを選んだ際は、専用の受注登録画面へ遷移させる。

  • 検索条件の保持: searchParams を文字列として取得し、遷移先のURLにも付与する。これにより、編集後に一覧へ戻っても検索条件を復元できる
  • Dynamic Routes(動的ルーティング): 受注IDごとに一意のURL(/order/[id])を割り当てる

Dynamic Routes のおさらい

  • ディレクトリ: src/app/(protected)/order/[id]/page.tsx
  • 値の受け取り: page.tsxparams から、id(今回の場合は 受注ID)を受け取る
受注一覧の編集アイコン
<Button
  onClick={() => {
    // 現在の検索条件(クエリ文字列)を取得
    const query = searchParams.toString();
    // IDを指定しつつ、検索条件も引き継いで遷移
    router.push(
      query
        ? `/order/${order.受注ID}?${query}`
        : `/order/${order.受注ID}`,
    );
  }}
  title="修正"
>
  <Pencil className="h-4 w-4" />
</Button>

✅ 「+新規受注」ボタンクリック時

まだIDが存在しない新規作成時は、専用の new ページへ飛ばす。
ここでも一覧の検索条件を引き継いでおくことで、保存後の「一覧への戻り」がスムーズになる。

受注一覧の新規ボタン
<Button
  type="button"
  onClick={() => {
    const query = searchParams.toString();
    // 新規専用パス '/order/new' へ遷移
    router.push(query ? `/order/new?${query}` : "/order/new");
  }}
>
  <Plus className="mr-2 h-4 w-4" /> 新規受注
</Button>

「編集用」と「新規用」のページを分けるメリット
「一つのファイルで id があるかないかを判定すればいいのでは?」と思う方は鋭いです。
ただ、ページ(page.tsx)を分けることで以下のメリットがあります。

  1. ルーティングの明快さ: URLを見ただけで「新規作成中か、既存編集か」がすぐわかる
  2. ロジックの分離: page.tsx は「新規用の初期値を渡す」「IDを元にDBからデータを取って渡す」という指示を出すだけでよくなり、コードがシンプルに保てる
  3. コンポーネントの共通化: 複雑な入力フォーム(受注Form.tsx)は共通化して、両方のページから呼び出すだけでOK

3.ヘッダ・明細形式の複雑なバリデーション(Zod)

マスタメンテに比べて、受注の入力チェックは親子関係もあり複雑です。

✅ Drizzle用のDBテーブル スキーマ(最終的な保管ルール)

src/db/schema.ts
export const 受注 = pgTable(
  "受注",
  {
    受注ID: text("受注ID")
      .primaryKey()
      .$defaultFn(() => uuidv7()),
    受注日: date("受注日").notNull(),
    得意先ID: text("得意先ID").notNull(),
    得意先名: text("得意先名").notNull(),
    合計金額: decimal("合計金額").notNull(),
    備考: text("備考"),
    version: integer("version").default(0).notNull(),
    createdAt: timestamp("created_at").defaultNow().notNull(),
    updatedAt: timestamp("updated_at")
      .defaultNow()
      .$onUpdate(() => /* @__PURE__ */ new Date())
      .notNull(),
  },
  (table) => [index("受注_受注日_idx").on(table.受注日)],
);

export const 受注明細 = pgTable(
  "受注明細",
  {
    受注明細ID: text("受注明細ID")
      .primaryKey()
      .$defaultFn(() => uuidv7()),
    受注ID: text("受注ID")
      .notNull()
      .references(() => 受注.受注ID, { onDelete: "cascade" }),
    商品CD: text("商品CD").notNull(),
    商品名: text("商品名").notNull(),
    単価: decimal("単価").notNull(),
    数量: decimal("数量").notNull(),
    明細金額: decimal("明細金額").notNull(),
  },
  (table) => [index("受注明細_受注ID_idx").on(table.受注ID)],
);

export const 受注Relations = relations(受注, ({ many }) => ({
  受注明細: many(受注明細),
}));

export const 受注明細Relations = relations(受注明細, ({ one }) => ({
  受注: one(受注, {
    fields: [受注明細.受注ID],
    references: [受注.受注ID],
  }),
}));

DB制約と relations の役割分担

  • .references(...): DBエンジン側で物理的な不整合を防ぐ。onDelete: "cascade" により、親を消せば子も自動消去される
  • relations: アプリケーション側で、複雑な Join クエリを驚くほど短く書けるようにする

本実装におけるDB設計スタンス

  • 日本語のテーブル名・列名: ドメインの理解しやすさを最優先。翻訳コストとバグを減らす
  • マスタ情報の「焼き付け」: 受注時の「得意先名」「商品名」「単価」などは、後のマスタ改定の影響を受けないよう、その瞬間の値を保存する設計です(あえて正規化を崩してます)

✅ 入力チェック用の 受注Model スキーマ(ブラウザからの橋渡し)

  • 画面入力時は、必須項目も一時的に空文字になりえる
  • 画面入力時は、数値項目にも一時的に文字列が入る可能性がある

入力時に認める型(Input)から、最終的な型(Output)までの変換ルールをZodで記述する。

src/db/model/受注Model.ts
import { z } from "zod";
import { dateStringSchema } from "./dateStringSchema";
import { nonNegativeNumericSchema } from "./nonNegativeNumericSchema";

export const 受注明細Model = z.object({
  受注明細ID: z.string().optional(),
  受注ID: z.string().optional(),
  商品CD: z.string().min(1, "商品は必須です"),
  商品名: z.string().min(1, "商品名は必須です"),
  単価: nonNegativeNumericSchema("単価は必須です"),
  数量: nonNegativeNumericSchema("数量は必須です"),
  明細金額: nonNegativeNumericSchema("明細金額は必須です"),
});

export const 受注Model = z.object({
  受注ID: z.string().optional(),
  受注日: dateStringSchema,
  得意先ID: z.string().min(1, "得意先は必須です"),
  得意先名: z.string().min(1, "得意先名は必須です"),
  合計金額: nonNegativeNumericSchema("合計金額は必須です"),
  備考: z.string().optional().nullable(),
  version: z.number().default(0),
  明細: z.array(受注明細Model).min(1, "明細を1件以上入力してください"),
});

// 型定義
export type 受注明細Input = z.input<typeof 受注明細Model>;
export type 受注明細Output = z.output<typeof 受注明細Model>;

export type 受注Input = z.input<typeof 受注Model>;
export type 受注Output = z.output<typeof 受注Model>;

// 一覧画面(検索結果)用のスキーマ:明細を省略する
export const 受注HeaderModel = 受注Model.omit({ 明細: true });
export type 受注HeaderOutput = z.output<typeof 受注HeaderModel>;

「1対N」のバリデーションを1つのスキーマで完結させる強み

受注ヘッダと複数の明細行を別々にバリデーションするのではなく、z.array() を使って1つの 受注Model 内のチェックにまとめてます。

💡 数値の共通チェック処理 nonNegativeNumericSchema

  • 入力時の空文字や文字列を数値へ変換・検証する共通スキーマ
  • .coerce.number() による意図しない「0」への変換を unionrefine で回避
src/db/model/nonNegativeNumericSchema.ts
import { z } from "zod";

export const nonNegativeNumericSchema = (requiredMsg: string) =>
  z
    .union([z.number(), z.string()])
    .refine((v) => String(v).trim() !== "", requiredMsg)
    .pipe(z.coerce.number())
    .refine((v) => !Number.isNaN(v), "数値を入力してください")
    .refine((v) => v >= 0, "0以上で入力してください");

💡 日付の共通チェック処理 dateStringSchema

  • yyyy-MM-dd 形式であることを正規表現でチェック
  • さらに 実在する日付かどうか も検証する
src/db/model/dateStringSchema.ts
import { z } from "zod";

export const dateStringSchema = z
  .string()
  .regex(/^\d{4}-\d{2}-\d{2}$/, "日付形式(yyyy-MM-dd)で入力してください")
  .refine((val) => {
    const [y, m, d] = val.split("-").map(Number);
    const date = new Date(y, m - 1, d);
    // JSのDateは「2月31日」を「3月3日」などに自動変換するため、
    // 入力値と変換後の値が一致するかで実在性を判定する
    return (
      date.getFullYear() === y &&
      date.getMonth() === m - 1 &&
      date.getDate() === d
    );
  }, "実在しない日付です");

✅ クライアントコンポーネントでの利用

useForm にてスキーマの指定

  • Formが扱う型は入力用の「受注Input」
  • resolverはバリデーションと型変換を含む「受注Model」
/src/app/(protected)/order/_components/受注Form.tsx
const form = useForm<受注Input>({
  resolver: zodResolver(受注Model),
  defaultValues: initialData || {
    受注日: serverDate,
    得意先ID: "",
    得意先名: "",
    明細: [{ 商品CD: "", 商品名: "", 単価: 0, 数量: 1, 明細金額: 0 }],
    合計金額: 0,
    version: 0,
  },
});
/src/app/(protected)/order/new/page.tsx
const today = new Intl.DateTimeFormat("sv-SE", {
  timeZone: "Asia/Tokyo",
}).format(new Date()); // "2026-03-02"

受注日の初期値 serverDate について

  • サーバーコンポーネント側で日付を取得する(クライアントの日付を信頼しない)
  • クライアントコンポーネントは serverDate をPropsで受け取るだけ

入力結果の取得

  • useFormで指定した「受注Input」型でまずは受け取る
  • zodResolver によりsubmit前に検証済みのため、クライアント側での再parseは不要
src/app/(protected)/order/_components/受注Form.tsx
const onSubmit = async (data: 受注Input) => {
  const res = await save受注(data, mode, initialData?.受注ID);

4.動的な明細追加・削除(useFieldArray

「明細行を自由に追加・削除したい」という要件は、業務アプリではよくあると思います。
React Hook Form の useFieldArray を使うことで、複雑な配列操作をスマートに実装できます。

✅ useFieldArray による動的な行管理

配列の状態(State)を自分で管理すると、レンダリングの最適化や ID の管理が非常に面倒になる。useFieldArray を使うと、各行に一意の key が自動で割り振られ、追加(append)や削除(remove)もメソッドを呼ぶだけになる。

src/app/(protected)/order/_components/受注Form.tsx
// 動的に変化する明細行を管理する
const { fields, append, remove } = useFieldArray({
  control: form.control,
  name: "明細",
});

// ボタンクリックで新しい空の明細を追加(最大10明細まで)
onClick={() =>
  append({
    商品CD: "",
    商品名: "",
    単価: 0,
    数量: 1,
    明細金額: 0,
  })
}
disabled={fields.length >= 10}

// ゴミ箱アイコンで特定の行を削除(最低1行は残す)
onClick={() => remove(index)}
disabled={fields.length <= 1}

💡 useFieldArray の解剖図

control: form.control:

  • 「どのフォームに所属しているか」を伝えるコネクタ
  • これがあるから、親フォームと明細行が同期できる

name: "明細":

  • 「フォームデータの中のどのキーを配列として扱うか」というターゲットの指定。

fields(ここが重要!):

  • Reactが各行を識別するための内部ID(id)を自動付与した配列
  • 展開すると、中身は [{ id: "uuid-1", 商品CD: "A", ... }, { id: "uuid-2", ... }]
  • ポイント: map で回す時の key={field.id} に使うために、ライブラリが勝手に一意のIDを振ってくれる

append / remove:

  • 実行と同時にバリデーションと再レンダリングをセットで行う命令

disabled={fields.length >= 10}

  • 明細がすでに10件以上ある場合、追加ボタンを利用禁止

disabled={fields.length <= 1}

  • 明細がすでに1件以下の場合、削除ボタンを利用禁止

📝 ひとつだけ注意点(実務Tips)

fields を map で回して Input を作る際、name の指定は以下のように書くルールになっている。

<Input {...form.register(`明細.${index}.商品名`)} />

field.商品名 を直接 value に入れるのではなく、「明細のn番目のこの項目」というパスを register に教えることで、useFieldArray が正しく値を追跡できるようになる。

実際の受注Formでの利用例

src/app/(protected)/order/_components/受注Form.tsx
{fields.map((field, index) => (
  <TableRow key={field.id} className="hover:bg-slate-50/20">
    // 中略
    <TableCell className="py-2">
      <Input
        type="number"
        {...form.register(`明細.${index}.数量`)}
        className="h-9 text-right font-mono text-xs focus:bg-white"
      />

key={field.id} の重要性 なぜ index ではなく field.id なのか?
もし index を key に使うと、途中の行を削除した際に React が「どの行が消えて、どの行が残ったか」を正しく判別できず、入力中の値が別の行にズレる というバグが発生する。field.id はライブラリが発行する不変のIDなので、これを使うことで安全な操作が保証される。


5.「リアルタイム計算」と「自動セット」の連動

業務アプリの使い勝手を決めるのは、 「自動化」 だと思います。
数量を変えたら小計が変わり、合計も連動して変わる。
これを useWatch のみで、useEffect を使わずに実現します。

✅ リアルタイムな金額計算ロジック

「明細の単価や数量が変わったこと」を検知し、即座に小計と合計金額を算出する。

  • 明細の小計 単価 ✕ 数量
  • 受注の合計 明細の小計の集計結果
// 明細配列全体を監視する(これだけでOK)
const watchDetails = useWatch({ control: form.control, name: "明細" });

useWatch で明細配列全体を監視することで、単価・数量・行の追加削除、すべての変化を1つの変数で捕捉できる。小計・合計は watchDetails からレンダリング時にその場で計算するため、useEffectsetValue も不要になる。

小計と合計の表示は以下のように計算式で直接算出する。

// 小計(行ごと)
value={formatJpy(
  (Number(watchDetails?.[index]?.単価) || 0) *
    (Number(watchDetails?.[index]?.数量) || 0),
)}

// 合計金額(¥記号は別表示のため数値のみ)
{(
  watchDetails?.reduce(
    (sum, item) =>
      sum + (Number(item.単価) || 0) * (Number(item.数量) || 0),
    0,
  ) || 0
).toLocaleString()}
  • useWatch で表示している小計・合計は、あくまで ユーザーへのプレビュー
  • onSubmit はフォームの入力値をそのまま渡すだけ。
  • サーバーアクション save受注 にて、実際にDBへ保存する金額を計算し、計算上の矛盾を排除する。
src/app/(protected)/order/_components/受注Form.tsx
const onSubmit = async (data: 受注Input) => {
  const res = await save受注(data, mode, initialData?.受注ID);
  ...

✅ 商品選択時の「焼き付け」処理

「商品マスタから商品を選んだ」というアクションをきっかけに、他の項目(商品名・単価)を自動で焼き付ける。前章のポリシー通り、ここで値をコピーして持つことで「スナップショット」を完成させます。

<AdvancedCombobox
  onSelect={(product) => {
    form.setValue(`明細.${index}.商品CD`, product.商品CD);
    form.setValue(`明細.${index}.商品名`, product.商品名); // 商品名をコピー(焼き付け)
    form.setValue(`明細.${index}.単価`, product.単価);      // 当時の単価をコピー(焼き付け)
  }}
/>

6.トランザクションによる安全な保存処理

受注データは「ヘッダ」と「明細」がセットなので、必ず一つのトランザクション内で処理します。

✅ Server Actions:保存処理の入り口

クライアント側の onSubmit から呼び出される関数。明細金額・合計金額の計算もここで行う。 クライアントが送ってくる金額は信頼せず、受け取った単価・数量からサーバー側で正しく算出しなおす。

src/app/(protected)/order/actions.ts
export async function save受注(
  data: 受注Input,
  mode: "create" | "edit",
  orderId?: string,
) {
  // 認証ガード
  await requireSession();

  try {
    // 明細金額・合計金額はサーバー側で計算する(クライアント値は信頼しない)
    const 明細 = data.明細.map((item) => ({
      ...item,
      明細金額: (Number(item.単価) || 0) * (Number(item.数量) || 0),
    }));
    const validated = 受注Model.parse({
      ...data,
      合計金額: 明細.reduce((sum, item) => sum + item.明細金額, 0),
      明細,
    });
    await 受注Repository.Save(validated, mode, orderId);

    // 関連するページのキャッシュをクリア
    revalidatePath("/dashboard");
    revalidatePath("/order");

    return { success: true };
  } catch (e) {
    console.error("Save Error:", e);

    if (e instanceof ZodError) {
      return { success: false, error: "入力内容に不備があります。画面の指示に従ってください。" };
    }
    if (e instanceof Error) {
      return { success: false, error: e.message };
    }
    return { success: false, error: "予期せぬエラーが発生しました。時間をおいて再度お試しください。" };
  }
}

💡 この実装の「実務の勘所」

(1) 認証ガード

  • Server Actions は公開されたエンドポイントと同じ
  • クライアント側のチェックをバイパスした悪意のあるリクエストを防ぐため、BetterAuthの機能を利用して認証を確認する
src/lib/auth-guard.ts
export async function requireSession() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });
  if (!session) redirect("/login");
  return session;
}

(2) サーバーサイドでのバリデーション

  • クライアント側のチェックをバイパスした悪意のあるリクエストを防ぐため、受注Model.parse(data) による型と値の最終確認は必須

(3) revalidatePath によるデータ鮮度の保証

  • 保存成功直後に revalidatePath("/order") を実行することで、サーバー側のデータ更新を通知
  • 一覧画面へ戻った際に「古いデータが表示される」という不整合を確実に防ぐ

✅ 受注Repository:db.transaction による実装

Drizzle ORM の db.transaction を使用します。
トランザクション内のすべてのクエリは db. ではなく tx. を使って実行します。
throw が発生した時点で自動的にロールバックされます。

async save(data: 受注Output, mode: "create" | "edit", orderId?: string) {
  const targetId = mode === "edit" ? orderId! : uuidv7();

  const headerValues = {
    受注日: data.受注日,
    得意先ID: data.得意先ID,
    得意先名: data.得意先名,
    合計金額: data.合計金額.toString(),
  };

  const detailValues = data.明細.map((m) => ({
    受注ID: targetId,
    商品CD: m.商品CD,
    商品名: m.商品名,
    単価: m.単価.toString(),
    数量: m.数量.toString(),
    明細金額: m.明細金額.toString(),
  }));

  if (mode === "edit") {
    await db.transaction(async (tx) => {
      const updateResult = await tx
        .update(受注)
        .set({ ...headerValues, version: data.version + 1 })
        .where(and(eq(受注.受注ID, targetId), eq(受注.version, data.version)));

      if (updateResult.rowCount === 0) {
        throw new Error(
          "対象のデータは別のユーザーによって更新されたか、削除されています。"
        );
      }

      await tx.delete(受注明細).where(eq(受注明細.受注ID, targetId));
      await tx.insert(受注明細).values(detailValues);
    });
  } else {
    await db.transaction(async (tx) => {
      await tx.insert(受注).values({ ...headerValues, 受注ID: targetId });
      await tx.insert(受注明細).values(detailValues);
    });
  }
},

💡 この実装の「実務の勘所」

(1) トランザクションによる一貫性の保持

  • 受注データはヘッダーと明細の2テーブルで構成される
  • この2つはセットで成功しないと、壊れた不完全なデータがDBに格納されてしまう
  • throw した時点で自動ロールバックされるため、中途半端な状態が残らない

(2) 確実な更新を実現する「Delete-Insert」パターン

  • 明細の更新は、1件ずつの update ではなく「全削除 → 全挿入」を採用
  • 行の増減や並び替えが発生する1:N構成において、ロジックが最も単純化され、不整合も起きにくい

(3) 楽観的ロック(Optimistic Locking)の厳格な判定

  • update().where() は一致するレコードがない場合、エラーを投げず「0件更新」で終了する
  • そのため、自ら updateResult.rowCount === 0 を判定し、明示的に throw する必要がある
  • これにより、古い画面を開いたままのユーザーによる誤った上書きを物理的に阻止する

6-補足.NeonDB の注意点:HTTPドライバではトランザクションが使えない

NeonDBの公式ドキュメント通りに設定すると、デフォルトでHTTP接続になります。
HTTP接続では db.transaction() が使えません。 業務アプリを開発する際は必ずPool接続へ切り替えてください。

✅ なぜHTTP接続ではトランザクションが使えないのか

NeonDBの公式ドキュメント通りに設定すると、以下のようなHTTP接続になります。

// Drizzle example with the Neon serverless driver(公式ドキュメントのデフォルト)
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
const sql = neon(process.env.DATABASE_URL);
const db = drizzle(sql);

HTTP接続は「1リクエスト = 1クエリ」のステートレスな通信です。
トランザクションはコネクションを維持したまま複数のクエリを送る必要があるため、この接続方式では db.transaction() を呼び出すことができません。

接続方式 トランザクション 用途
HTTP接続(neon() ❌ 不可 単発クエリ、Edge Runtime
Pool接続(Pool ✅ 可 業務アプリ、複数テーブルの更新

✅ Pool接続への移行手順

① wsパッケージの追加

Pool接続はWebSocketを使用するため、ws パッケージが必要です。

npm install ws
npm install -D @types/ws

② drizzle.ts の書き換え

// 変更前(HTTP接続)
import { drizzle } from "drizzle-orm/neon-http";
import { schema } from "./schema";
export const db = drizzle(process.env.DATABASE_URL!, { schema });
// 変更後(Pool接続)
import { neonConfig, Pool } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-serverless";
import ws from "ws";
import { schema } from "./schema";

neonConfig.webSocketConstructor = ws;

const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
export const db = drizzle(pool, { schema });

importパスが drizzle-orm/neon-http から drizzle-orm/neon-serverless に変わる点に注意してください。
db のインターフェース自体は変わらないので、クエリ部分(select・insert等)は一切修正不要です。

drizzle.config.ts は変更不要

drizzle.config.ts はマイグレーション用(drizzle-kit)の設定であり、アプリの接続方式とは独立しています。そのままで大丈夫です。

💡 まとめ:変更箇所は3つだけ

  1. ws パッケージの追加
  2. drizzle.ts の接続方式変更(importパスと接続オブジェクトの差し替え)
  3. db.batchdb.transaction への書き換え(db.tx. に変更)

7.その他のUXを向上させる「現場」のひと工夫

システムを「動くもの」から「使いやすい道具」へ変えるための、実装のこだわりをまとめました。

(1) 曜日の自動表示(getDayOfWeekInfo)

  • 日付を入力した瞬間に「(月)」や「(日)」を横に表示し、さらに日曜は赤、土曜は青に色分けします
    • 理由: 「月曜だと思って入力したら実は日曜だった」という日付のズレは、業務システムでは出荷日のミスなどに直結します。視覚的な補助で、入力ミスにその場で気づけるようにしています

(2) 合計金額の強調

  • 画面下部に固定の黒帯を配し、常に巨大なフォントで合計金額を表示します
    • 理由: 受注登録において「結局、いくらなのか」は最も重要な情報です。強調し常に視界に入る位置へ置くことで、ユーザーに安心感を与えます

(3) 削除確認ダイアログ

  • AlertDialog を使い、削除実行前に「本当に消しますか?」という2重チェックを行います
    • 理由: 誤操作で1つの伝票を消してしまうダメージは計り知れません。「戻せない操作」には必ずワンクッション置きます

(4) 操作不能状態(フリーズ)を防ぐ

  • 検索や画面遷移などの重い処理には useTransition を、コンポーネントの読み込み待ちには Suspense を活用しています
    • 理由: 通信中に画面全体が固まる「フリーズ感」はユーザーを不安にさせます。バックグラウンドで処理を回しつつ、ローディングインジケーターを出すことで「システムは動いている」という確信を持たせます

(5) 受注IDに UUID v7 を採用

  • IDには連番(1, 2, 3...)ではなく、最新の規格である UUID v7 を採用しています
    • セキュリティ: 連番だとURLを推測(order/100 の次を order/101 と打つなど)して他人のデータを覗き見されるリスクがあるが、UUIDなら防げる
    • 性能: v4(ランダム)と違い、v7はタイムスタンプが含まれているため時系列順に並ぶという特性がある。これによりDBのインデックス効率が上がり、検索が高速になる

✨ UUID v7の特徴

  • タイムスタンプベース で時間順にソート可能
  • データベースのインデックス(B-Tree)に優しい:完全ランダムなv4と違い、Auto Incrementな連番IDに近い特性を持つためDBの書き込みパフォーマンスが劣化しない
  • 高い衝突耐性と推測不可能性:ミリ秒精度の時間と乱数を組み合わせるため、推測が不可能かつ重複しない

8.入力支援用共通部品:マルチカラム・コンボボックス

業務システムで「検索して選ぶ」ことをどれだけ便利にできるか?はとても重要と思います。

🔎 実装イメージ
image.png

🔤 実装コード

src/components/advanced-combobox.tsx

✅ なぜ自作したのか?

  • 検索性:  必ず商品CDを知っているとは限らない(商品名でも検索したい)
  • 情報量:  商品名だけでは選択し切れない(コードや単価も同時に見たい)
  • 操作性:  入力中にリアルタイムでDBを検索し、候補を出したい
  • 汎用性:  得意先検索でも商品検索でも、同じ操作感を提供したい

✅ 実装のこだわり:AdvancedCombobox
このコンポーネントは、Shadcn/UI の Command コンポーネントをベースにしつつ、以下の機能を独自に組み込んでいます。

  • デバウンス検索:  useDebounce により、入力のたびにDBへリクエストが飛ぶのを防ぐ
  • テーブルレイアウト:  検索結果を ColumnDef に従って複数列で表示 font-mono を使って数値を揃えるなどの細かな調整も
  • 自動フォーマット:  isCurrency オプションにより、検索結果内の金額に自動で「¥」とカンマを付与
  • 「キーボード操作への配慮」:  Command コンポーネントを使っていることで、上下キーでの選択や Enter での確定といった、マウスを使わない高速な入力作業にも対応可能

共通部品の呼び出し側は指定プロパティは多いですが、それだけ細かな指定が可能です

受注Formから商品CD検索の呼び出し部分
<AdvancedCombobox<ProductSearchRes>
  key={field.商品CD || `new-product-${index}`}
  placeholder="CD検索..."
  searchFn={search商品}
  columns={productColumns}
  displayKey="商品CD"
  valueKey="商品CD"
  initialValue={
    field.商品CD
      ? {
          商品CD: field.商品CD,
          商品名: field.商品名,
          単価: Number(field.単価),
        }
      : undefined
  }
  onSelect={(product) => {
    form.setValue(
      `明細.${index}.商品CD`,
      product.商品CD,
    );
    form.setValue(
      `明細.${index}.商品名`,
      product.商品名,
    );
    form.setValue(`明細.${index}.単価`, product.単価);
  }}
/>

「焼き付け」との相性が抜群
このコンボボックスは選択された「レコード丸ごと」を onSelect に返します。そのため、IDだけでなく「その時の商品名」や「その時の単価」をトランザクションにコピーする(焼き付ける)実装が非常に楽になります。


🚀 まとめ

僕自身も探求中の身ですが、React/Next.js(App Router)のベストプラクティスをできるだけキャッチアップして、業務アプリケーションへ落とし込んでみました。
今回構築した設計思想やテンプレートが、皆さんの素晴らしいプロダクト開発の一助になれば幸いです!

1
2
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?