React 削除確認をインラインUIで実装する方法

React 削除確認をインラインUIで実装する方法 コラム
React 削除確認をインラインUIで実装する方法のイメージ

「削除ボタンを押したら window.confirm が出てきた」という体験、今でも古臭く感じませんか?

モーダルダイアログで確認するアプローチは定番ですが、実装が複雑になりがちで、スクリーンリーダーとの相性も良くないケースがあります。一方、別ページに遷移して「本当に削除しますか?」と確認する方法は UX が大きく損なわれます。

この記事では、ページ遷移もモーダルも使わず、ボタン自体がその場で「削除しますか?」確認UIに変化するインライン削除確認パターンについて解説します。Next.js App Router の Server Actions と組み合わせた実装を中心に、DeleteConfirmButton コンポーネントの完全な TypeScript コードをお伝えします。結論から書くと、useState で表示状態を切り替えてフォームで Server Action を呼び出す構成が、シンプルで堅牢な解決策です。管理画面3件・予約管理ツール1件で採用した実績をもとに、実際のハマりどころも含めて紹介します。

📋 この記事でわかること

  • 削除確認UIの4つのアプローチ比較(比較表あり)
  • インラインUIパターンを選ぶ理由とUXメリット
  • DeleteConfirmButton コンポーネントの完全なTypeScriptコード
  • Next.js App Router の Server Actions との連携方法
  • セッション詳細ページへの組み込み例
  • よくある実装ミスと対処法

削除確認UIの4つのアプローチを比較する

削除確認UIの4つのアプローチを比較するのイメージ

削除確認UIを実装するアプローチは大きく4つあります。実際に各パターンを使ってきた経験から、それぞれの特徴をまとめると以下のとおりです。

アプローチ UX 実装コスト アクセシビリティ Server Actions との相性
window.confirm() ダイアログ ✅ シンプル ✅ 最小 ❌ カスタム不可 ❌ 非推奨(ブロッキング)
別ページへ遷移して確認 ❌ コンテキスト断絶 ❌ ルーティング必要 ✅ 問題なし ✅ GET/POST 可
インラインUI(本記事) ✅ コンテキスト維持 ✅ 中程度 ✅ カスタム可 ✅ form action と相性◎
モーダルダイアログ ✅ 視覚的に分離 ❌ 高(Portal, focus管理) ⚠️ focus trap 必要 ⚠️ useTransition 必要

インラインUIは「コストが低くてUXが良い」という中間点を取れるのが大きなメリットです。モーダルは focus trap や aria-modal の実装が必要になり、正しく実装しようとすると意外と工数がかかります。

インラインUIパターンを選ぶ理由

インラインUIパターンを選ぶ理由のイメージ

実際に管理画面3件・予約管理ツール1件のプロジェクトで試した結果、インラインパターンが優れている理由が3点あります。

1. コンテキストが切れない

削除ボタンが「削除しますか?」に変化するので、ユーザーは「何を削除しようとしているか」を画面上で確認しながら操作できます。別ページに飛ぶと、戻ったときに「どこを操作していたっけ?」となりがちです。

2. 実装がシンプル

モーダルの場合、Portal で DOM ツリーの外に描画し、focus trap を設定し、Escape キーで閉じる処理を書く必要があります。インラインなら useState 一つで制御できます。

3. Server Actions の form action と自然に組み合わせられる

Next.js App Router では <form action={serverAction}> という書き方が推奨されます。インラインUIは hidden input で必要なパラメータを渡すだけで、特別な非同期処理を書かずに済みます。

例えば、セッション管理ツールや管理画面のリスト表示で「削除」をよく使う場面を考えてみてください。1ページに複数の削除ボタンがある場合、モーダルの管理は複雑になります。インラインなら各行が独立したコンポーネントとして動作するため、管理が楽です。

DeleteConfirmButton コンポーネントの実装

DeleteConfirmButton コンポーネントの実装のイメージ

それでは実際のコードを見ていきましょう。

コンポーネントの全コード


// components/DeleteConfirmButton.tsx
'use client';

import { useState } from 'react';
import type { ComponentProps } from 'react';

type Props = {
  /** Server Action(form の action に渡す) */
  action: (formData: FormData) => Promise<void> | void;
  /** Server Action に渡す hidden input のキーと値 */
  hiddenInputs?: Record<string, string>;
  /** 削除ボタンのラベル(デフォルト: "削除") */
  label?: string;
  /** 確認フェーズの確定ボタンのラベル(デフォルト: "削除する") */
  confirmLabel?: string;
  /** クラス名(外部からスタイルを注入したい場合) */
  className?: string;
};

export function DeleteConfirmButton({
  action,
  hiddenInputs = {},
  label = '削除',
  confirmLabel = '削除する',
  className,
}: Props) {
  const [confirming, setConfirming] = useState(false);

  if (!confirming) {
    return (
      <button
        type="button"
        onClick={() => setConfirming(true)}
        className={className}
      >
        {label}
      </button>
    );
  }

  return (
    <span className="inline-flex items-center gap-2">
      <span className="text-sm text-gray-700">削除しますか?</span>
      <form action={action} onSubmit={() => setConfirming(false)}>
        {Object.entries(hiddenInputs).map(([name, value]) => (
          <input key={name} type="hidden" name={name} value={value} />
        ))}
        <button
          type="submit"
          className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
        >
          {confirmLabel}
        </button>
      </form>
      <button
        type="button"
        onClick={() => setConfirming(false)}
        className="rounded border px-3 py-1 text-sm"
      >
        キャンセル
      </button>
    </span>
  );
}

コードの設計ポイント

'use client' が必要な理由

useState を使うため、このコンポーネントは Client Component になります。Server Component のツリー内に配置する場合も、このファイルだけが 'use client' になり、親コンポーネントは Server Component のままでOKです。

form action={action} の書き方

Server Action を action プロップで受け取り、<form action={action}> に渡します。Next.js App Router では form の action に async 関数を直接渡せるのがポイントです。

hidden inputs の渡し方

hiddenInputsRecord<string, string> で受け取り、Object.entries でループして <input type="hidden"> を生成します。削除対象の ID などをここで渡します。

キャンセル時の処理

キャンセル ボタンはフォームの外に置き、onClick={() => setConfirming(false)} で状態を戻します。フォームに含めると type=”submit” または type=”reset” が必要になり、意図しないフォーム送信が起きる可能性があります。

Server Action との組み合わせ方

Server Action との組み合わせ方のイメージ

Server Action の実装例


// app/sessions/[id]/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function deleteSession(formData: FormData) {
  const sessionId = formData.get('sessionId');

  if (!sessionId || typeof sessionId !== 'string') {
    throw new Error('sessionId が不正です');
  }

  await prisma.session.delete({
    where: { id: sessionId },
  });

  revalidatePath('/sessions');
  redirect('/sessions');
}

セッション詳細ページへの組み込み例


// app/sessions/[id]/page.tsx
import { DeleteConfirmButton } from '@/components/DeleteConfirmButton';
import { deleteSession } from './actions';

export default async function SessionDetailPage({ params }: { params: { id: string } }) {
  const session = await getSession(params.id);

  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold">{session.title}</h1>
      <p className="mt-2 text-gray-600">{session.description}</p>
      <div className="mt-8 flex gap-4">
        <a href={`/sessions/${params.id}/edit`} className="btn-secondary">編集</a>
        <DeleteConfirmButton
          action={deleteSession}
          hiddenInputs={{ sessionId: params.id }}
          className="btn-danger"
        />
      </div>
    </main>
  );
}

このように、Server Component(page.tsx)から Client Component(DeleteConfirmButton)に Server Action を prop として渡す構成になります。deleteSession'use server' で定義した関数なので、クライアント側には関数の参照(アクションID)だけが渡り、実際の処理はサーバーで実行されます。

リスト表示での複数削除ボタンの管理

リスト表示での複数削除ボタンの管理のイメージ

管理画面のようなリスト表示で、各行に削除ボタンを持たせる場合も同じパターンが使えます。


// app/sessions/page.tsx
import { DeleteConfirmButton } from '@/components/DeleteConfirmButton';
import { deleteSession } from './[id]/actions';

export default async function SessionsPage() {
  const sessions = await getSessions();

  return (
    <div className="space-y-4">
      {sessions.map((session) => (
        <div key={session.id} className="flex items-center justify-between rounded border p-4">
          <div>
            <h2 className="font-semibold">{session.title}</h2>
            <p className="text-sm text-gray-500">{session.createdAt}</p>
          </div>
          <DeleteConfirmButton
            action={deleteSession}
            hiddenInputs={{ sessionId: session.id }}
            label="削除"
            confirmLabel="削除する"
          />
        </div>
      ))}
    </div>
  );
}

各行の DeleteConfirmButton は独立した useState を持つため、1行の確認状態が他の行に影響しません。モーダルで1つの開閉状態を共有する場合と比べて、管理が大幅にシンプルになります。

よくある実装ミスと対処法

実際にこのパターンを導入する際にハマりやすいポイントを紹介します。

ミス1: キャンセルボタンを form の中に入れてしまう


// ❌ NG
<form action={action}>
  <input type="hidden" name="sessionId" value={id} />
  <button type="submit">削除する</button>
  <button onClick={() => setConfirming(false)}>キャンセル</button>
</form>

// ✅ OK: form の外に置くか type="button" を明示する
<form action={action}>
  <input type="hidden" name="sessionId" value={id} />
  <button type="submit">削除する</button>
</form>
<button type="button" onClick={() => setConfirming(false)}>キャンセル</button>

HTML の <button> はデフォルトで type="submit" になります。フォーム内のキャンセルボタンに type="button" を付け忘れると、クリックと同時にフォームが送信されてしまいます。

ミス2: Server Action で ‘use server’ を付け忘れる

Server Action を受け取る action プロップは必須ですが、TypeScript の型では undefined を弾けます。ただし、Action ファイル側で 'use server' を付け忘れると実行時エラーになります。確認方法は以下のとおりです。


// ✅ 確認ポイント
// 1. actions.ts の先頭に 'use server' があるか
// 2. 関数が async になっているか(Server Action は async 関数が必要)
// 3. formData.get() で取得した値の null チェックをしているか

ミス3: confirming 状態が意図せずリセットされる

DeleteConfirmButton を持つ親コンポーネントが再レンダリングされると、子コンポーネントも再レンダリングされます。ただし、React は同じ位置の同じコンポーネントについて state を保持するため、通常は問題になりません。一方、親が key プロップを変えると state がリセットされます。リスト表示で key={session.id} を正しく設定しておくことが重要です。

よくある質問

Q: この実装はアクセシビリティ的に問題ありませんか?

A: role="status"aria-live を追加すると、スクリーンリーダーに確認フェーズへの切り替えを伝えられます。最低限の対応として、確認メッセージのテキストが DOM に存在することが重要で、モーダルのような focus trap は不要です。本番環境では aria-label を適切に設定することをおすすめします。

Q: 削除中(pending 状態)はどう表示しますか?

A: useFormStatus を使う方法と useTransition を使う方法があります。削除ボタンを別の SubmitButton コンポーネントに分離して useFormStatus().pending を利用するのがシンプルです。


function SubmitButton({ label }: { label: string }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? '削除中...' : label}
    </button>
  );
}

Q: Next.js 以外の React プロジェクトでも使えますか?

A: form action={serverAction} は Next.js の機能ですが、インライン確認UIの useState パターン自体はどの React プロジェクトでも使えます。通常の React プロジェクトでは action の代わりに onConfirm コールバックを受け取る形に変えると汎用的です。

Q: 誤操作防止のために削除後に「元に戻す」機能を入れたい場合は?

A: Soft Delete(deletedAt フラグ)でDBの行を論理削除にして、一定時間後に物理削除するバックグラウンドジョブを組み合わせるのが一般的なアプローチです。インラインUIはその場で完結するため、Undo トースト通知と組み合わせやすい構成です。

Q: モーダルと使い分ける基準は何ですか?

A: 削除の影響範囲が大きい(大量データ・課金に影響する等)場合はモーダルや別ページのほうが適切です。インラインUIは「1件のレコード削除」のような軽い操作に向いています。

✅ まとめ

この記事では、React と Next.js App Router を使った削除確認のインライン UI パターンについて解説しました。

  • window.confirm / 別ページ遷移 / インラインUI / モーダルの4択を比較した
  • useStateconfirming フラグを管理し、true のときに確認フェーズのUIに切り替える
  • <form action={serverAction}><input type="hidden"> を組み合わせて Server Actions にデータを渡す
  • キャンセルボタンは form の外に置くか type="button" を明示する

次のアクションとしては、まず components/DeleteConfirmButton.tsx をコピペして既存プロジェクトに追加してください。追加したら、既存の window.confirm を使っている箇所に差し替えるだけで動きます。コンポーネントはプロジェクト横断で再利用できるため、一度作れば管理コストは最小になります。

Server Actions との組み合わせは Next.js 公式ドキュメントの Form Actions も参照してください。

コメント