ご挨拶失礼します!
こんばんは!本当にお久しぶりです!
最後に投稿したのが2/11でした!
難病のことについて投稿してそれから症状が治まるまでかつ今の仕事と向き合っていたら時間がかかってしまいました。
少しずつやってきたことをまた投稿しますのでお時間あるときに読んでいただけると幸いです。
感覚が空いていますが念を押させてください!初心者です!
今回のタイトルでお分かりかと思いますがNext.jsの4回目の投稿になります。
【Next.js + Server Actions】投稿削除のUXを高める!「本当に削除しますか?」アラートと useFormStatus によるローディング実装について投稿します!
はじめに
前回の記事で、Next.jsとSupabaseを組み合わせた「投稿削除機能」の基本を実装しました。
しかし、今の状態だと「ボタンを押した瞬間に確認なしで消えてしまう」、「削除処理中にボタンが連打できてしまう」という課題があります。
今回は、この課題を解決してユーザー体験(UX)を一気に高めるために、以下の2つを実装します!
- JavaScript標準の
confirm()を使った削除確認アラート - Reactの
useFormStatusを使った削除中のローディング(ボタン無効化)
1. 削除ボタンを「別コンポーネント」に切り出す理由
処理中のローディング状態を取得できる useFormStatus というReactのHook(フック)を使います。
このHookには1つルールがあり、<form> の内側にある子コンポーネントでしか状態を検知できません。そのため、今回は削除ボタンの部分を DeleteButton.tsx として新しく切り出します。
2. 実装コード
① 削除ボタンコンポーネントの作成
ボタン単体のコンポーネントを作成し、その中で useFormStatus を呼び出します。また、誤操作を防ぐために onClick イベントで確認アラートを挟みます。
"use client";
import { useFormStatus } from "react-dom";
import { Trash2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; // shadcn/uiを使用している場合
export default function DeleteButton() {
// 現在のフォームが送信中(pending)かどうかを取得
const { pending } = useFormStatus();
const handleDelete = (e: React.MouseEvent<HTMLButtonElement>) => {
// 「キャンセル」が押されたら、フォームの送信をストップする
if (!confirm("本当にこの投稿を削除しますか?")) {
e.preventDefault();
}
};
return (
<Button
type="submit"
variant="ghost"
size="icon"
className="text-red-400 hover:text-red-600 hover:bg-red-50"
disabled={pending} // 送信中はボタンを押せなくする(連打防止)
onClick={handleDelete}
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" /> // ローディング中のアイコン
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
);
}
② 掲示板ページ側の修正
作成した DeleteButton を、既存のフォーム内に組み込みます。ボタン自体は useFormStatus を使うために "use client"(クライアントコンポーネント)にしていますが、フォームを囲むページ自体は Server Componentsのまま維持できる のがNext.jsの強みです。
// ...既存のインポート
import DeleteButton from "@/components/DeleteButton";
export default async function BoardPage() {
// ...既存のデータ取得と deletePost 関数
return (
<div className="max-w-2xl mx-auto py-10 space-y-8">
{/* ...既存の投稿フォーム... */}
<div className="space-y-4">
{posts?.map((post) => (
<Card key={post.id}>
<CardContent className="pt-6">
<p>{post.content}</p>
<div className="flex justify-between items-center mt-2">
<span className="text-xs text-muted-foreground">
{new Date(post.created_at).toLocaleString()}
</span>
{/* 削除ボタンのフォーム */}
<form action={deletePost}>
<input type="hidden" name="id" value={post.id} />
{/* 切り出したコンポーネントを配置 */}
<DeleteButton />
</form>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
3. 今回の学びのポイント
-
e.preventDefault()による送信制御:confirm()の結果がfalse(キャンセル)だった場合にこれを実行することで、<form>がサーバーにデータを送るのを手前で食い止めることができます。 -
useFormStatusの制約: 何度もつまずきやすいポイントですが、useFormStatusは 「同じコンポーネント内の<form>」ではなく「親の<form>」の状態しか見られない ため、ボタンの切り出しが必要になります。 -
disabled={pending}による連打対策: ネットワークが遅い時にユーザーがボタンを何度もクリックして、存在しないデータを何度も消そうとするエラーを防ぐ必須の処理です。
おわりに
これで、間違えてゴミ箱ボタンを押してしまっても安心ですし、通信中のぐるぐる(ローディング)が表示されることで、ユーザーが「今処理中なんだな」と安心できる親切なアプリになりました!
時間が空いてしまっても、コードを少しずつ触ることで感覚はすぐに戻ってきます。
次は、さらにセキュリティを高めるために、「ログイン機能(Auth)を実装して、自分が書いた投稿だけを消せるようにする」 ステップに進みましょう!
次回について
少し時間はかかりそうですが自分が学んだことをまとめていければと思います。
このシリーズの投稿についてはすでにプログラム自体は完成していて少しずつ投稿していく形になっております。
また次にお会いできることを楽しみにし、かつ自分の理解も深めていきたいです!
一読いただきありがとうございました!