第150章:ミニ課題:編集・削除にもActionsを広げる✏️🗑️
今回は、Server Actionsで「編集」「削除」までできるようにして、TODOを「ちゃんとCRUDっぽく」仕上げるよ〜!🥳💪 (※DBはまだ入れない想定なので、今回はメモリ保存で学習します🧠✨)
今日のゴール🎯✨
- ✅ TODOを**編集(Update)**できる
- ✅ TODOを**削除(Delete)**できる
- ✅ 送信中はボタンが押せない(二重送信防止)🖱️🚫
- ✅ 失敗したら優しくエラー表示🧯💕
全体の流れ(図解)🧭✨

フォルダ構成(今回の完成形)📁✨
1) メモリ保存の「仮TODOストア」を作る🧠📦
これは学習用だよ!本番では次のDB章でちゃんと永続化するよ〜🗃️✨
lib/todos.ts を作ってね👇
export type Todo = { id: string; title: string; createdAt: number };
declare global {
// eslint-disable-next-line no-var
var __todos: Todo[] | undefined;
}
const getStore = (): Todo[] => {
if (!globalThis.__todos) globalThis.__todos = [];
return globalThis.__todos;
};
export function listTodos(): Todo[] {
return [...getStore()].sort((a, b) => b.createdAt - a.createdAt);
}
export function createTodo(title: string): Todo {
const todo: Todo = { id: crypto.randomUUID(), title, createdAt: Date.now() };
getStore().push(todo);
return todo;
}
export function updateTodo(id: string, title: string): Todo | null {
const store = getStore();
const t = store.find((x) => x.id === id);
if (!t) return null;
t.title = title;
return t;
}
export function removeTodo(id: string): boolean {
const store = getStore();
const before = store.length;
globalThis.__todos = store.filter((x) => x.id !== id);
return globalThis.__todos.length !== before;
}
2) Server Actions(追加・編集・削除)を用意する🧑🍳✨
app/todos/actions.ts を作ってね👇
(ここが今回のメイン!✏️🗑️✨)
"use server";
import { createTodo, updateTodo, removeTodo } from "@/lib/todos";
import { revalidatePath } from "next/cache";
export type TodoActionState = {
ok: boolean;
message?: string;
fieldErrors?: { title?: string };
};
const normalizeTitle = (v: unknown) => String(v ?? "").trim();
export async function addTodoAction(
_prevState: TodoActionState,
formData: FormData
): Promise<TodoActionState> {
const title = normalizeTitle(formData.get("title"));
if (title.length === 0) {
return { ok: false, fieldErrors: { title: "空っぽはダメだよ〜🥺" } };
}
if (title.length > 60) {
return { ok: false, fieldErrors: { title: "ちょい長いかも!60文字以内で✨" } };
}
createTodo(title);
revalidatePath("/todos");
return { ok: true, message: "追加できたよ🎉" };
}
export async function updateTodoAction(
_prevState: TodoActionState,
formData: FormData
): Promise<TodoActionState> {
const id = String(formData.get("id") ?? "");
const title = normalizeTitle(formData.get("title"));
if (!id) return { ok: false, message: "IDが見つからないよ…🫠" };
if (title.length === 0) {
return { ok: false, fieldErrors: { title: "空っぽはダメだよ〜🥺" } };
}
if (title.length > 60) {
return { ok: false, fieldErrors: { title: "ちょい長いかも!60文字以内で✨" } };
}
const updated = updateTodo(id, title);
if (!updated) return { ok: false, message: "そのTODOが見つからない…🫥" };
revalidatePath("/todos");
return { ok: true, message: "更新できたよ✏️✨" };
}
export async function deleteTodoAction(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "");
if (!id) return;
removeTodo(id);
revalidatePath("/todos");
}
3) 画面:/todos ページを作る📝✨
app/todos/page.tsx を作ってね👇
import { listTodos } from "@/lib/todos";
import CreateTodoForm from "./_components/CreateTodoForm";
import TodoItem from "./_components/TodoItem";
export default function TodosPage() {
const todos = listTodos();
return (
<main style={{ padding: 24, maxWidth: 720, margin: "0 auto" }}>
<h1>TODO 📝</h1>
<p>Server Actionsで 追加・編集・削除までやっちゃうよ〜✨</p>
<CreateTodoForm />
<ul style={{ listStyle: "none", padding: 0, marginTop: 16 }}>
{todos.map((t) => (
<li
key={t.id}
style={{
border: "1px solid #ddd",
borderRadius: 12,
padding: 12,
marginBottom: 12,
}}
>
<TodoItem todo={t} />
</li>
))}
</ul>
</main>
);
}
4) 追加フォーム(useActionStateで優しく)➕💕
app/todos/_components/CreateTodoForm.tsx
"use client";
import { useActionState } from "react";
import { addTodoAction, type TodoActionState } from "../actions";
import SubmitButton from "./SubmitButton";
const initialState: TodoActionState = { ok: false };
export default function CreateTodoForm() {
const [state, action] = useActionState(addTodoAction, initialState);
return (
<form action={action} style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
<div style={{ flex: 1 }}>
<input
name="title"
placeholder="TODOを入力…✍️"
aria-label="todo title"
style={{ width: "100%", padding: 10, borderRadius: 10, border: "1px solid #ccc" }}
/>
{state.fieldErrors?.title && (
<p style={{ marginTop: 6, color: "crimson" }}>{state.fieldErrors.title}</p>
)}
{state.message && (
<p style={{ marginTop: 6, color: state.ok ? "green" : "crimson" }}>{state.message}</p>
)}
</div>
<SubmitButton label="追加➕" pendingLabel="追加中…⏳" />
</form>
);
}
5) 送信中ボタン(useFormStatus)⏳🔒
app/todos/_components/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
export default function SubmitButton({
label,
pendingLabel,
danger,
}: {
label: string;
pendingLabel: string;
danger?: boolean;
}) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
style={{
padding: "10px 14px",
borderRadius: 10,
border: "1px solid #ccc",
background: danger ? "#ffe5e5" : "#f6f6f6",
cursor: pending ? "not-allowed" : "pointer",
}}
>
{pending ? pendingLabel : label}
</button>
);
}
6) 各TODOの「編集&削除」UIを作る✏️🗑️✨(ここが本題!)

app/todos/_components/TodoItem.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useActionState } from "react";
import type { Todo } from "@/lib/todos";
import { updateTodoAction, type TodoActionState, deleteTodoAction } from "../actions";
import SubmitButton from "./SubmitButton";
const initialState: TodoActionState = { ok: false };
export default function TodoItem({ todo }: { todo: Todo }) {
const [isEditing, setIsEditing] = useState(false);
const [state, updateAction] = useActionState(updateTodoAction, initialState);
// 「成功した瞬間だけ」編集モードを閉じる用(連続編集でも困らない✨)
const prevOkRef = useRef(false);
useEffect(() => {
if (!prevOkRef.current && state.ok) setIsEditing(false);
prevOkRef.current = state.ok;
}, [state.ok]);
return (
<div style={{ display: "grid", gap: 10 }}>
{!isEditing ? (
<div style={{ display: "flex", gap: 8, alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: 16 }}>{todo.title}</span>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => setIsEditing(true)}
style={{
padding: "8px 12px",
borderRadius: 10,
border: "1px solid #ccc",
background: "#f6f6f6",
cursor: "pointer",
}}
>
編集✏️
</button>
<form action={deleteTodoAction}>
<input type="hidden" name="id" value={todo.id} />
<SubmitButton label="削除🗑️" pendingLabel="削除中…⏳" danger />
</form>
</div>
</div>
) : (
<form action={updateAction} style={{ display: "grid", gap: 8 }}>
<input type="hidden" name="id" value={todo.id} />
<input
name="title"
defaultValue={todo.title}
style={{ width: "100%", padding: 10, borderRadius: 10, border: "1px solid #ccc" }}
/>
{state.fieldErrors?.title && <p style={{ marginTop: -2, color: "crimson" }}>{state.fieldErrors.title}</p>}
{state.message && (
<p style={{ marginTop: -2, color: state.ok ? "green" : "crimson" }}>{state.message}</p>
)}
<div style={{ display: "flex", gap: 8 }}>
<SubmitButton label="保存💾" pendingLabel="保存中…⏳" />
<button
type="button"
onClick={() => setIsEditing(false)}
style={{
padding: "10px 14px",
borderRadius: 10,
border: "1px solid #ccc",
background: "#f6f6f6",
cursor: "pointer",
}}
>
キャンセル🙅♀️
</button>
</div>
</form>
)}
<small style={{ opacity: 0.7 }}>id: {todo.id.slice(0, 8)}…</small>
</div>
);
}
動作確認✅✨(Windows)
-
ターミナルで起動🏃♀️💨
npm run dev -
ブラウザで開く🌐
http://localhost:3000/todos -
✅ 追加 ➕
-
✅ 編集 ✏️ → 保存 💾
-
✅ 削除 🗑️(削除中…が出るよ⏳)
よくあるハマり🪤😵💫
<button>の type を書かないと submit になることがあるよ! → 編集開始ボタンはtype="button"にしてあるのがポイント✅- Server ActionsはブラウザAPI(window等)触れないよ〜🚫🌐
- 今回のストアはメモリだから、サーバー再起動で消えるよ🫠(学習用!)
ミニ課題🎓✨(やってみて!)
- 削除に確認を入れてみよう🗑️⚠️
- 「本当に消す?」を
confirm()で出したい - でも
confirm()はブラウザAPIなのでClient側でやる必要あり! ヒント:削除フォームのonSubmitで止める🖱️✨
- **編集時に“未入力なら保存ボタン無効”**にしてみよう✏️🚫
onChangeで入力を見て、空なら保存できないようにする(Client側)
- (余裕あれば💪)編集をモーダル風にしてみよう🪄✨
- 画面の上にふわっと編集フォームを出すイメージ!
これで Server Actionsで「追加・編集・削除」まで完走🎉だよ〜! 次のDB章に行く前の「手応え」めっちゃ大事なので、ぜひ触って遊んでね🥰🫶✨