Form dengan Server Actions
Pola form lengkap: pending state, error, dan sukses.
Tombol yang bisu itu berbahaya
Bayangkan kamu mengisi form kontak, menekan Kirim, lalu... tidak terjadi apa-apa selama dua detik. Nalurimu: tekan lagi. Dan lagi. Tiba-tiba tiga pesan identik terkirim ke admin. Inilah yang terjadi kalau tombol submit tidak tahu bahwa sebuah request sedang berjalan. Solusinya bernama pending state: tombol yang sadar diri, menonaktifkan dirinya dan mengubah teksnya selama action diproses.
useFormStatus: telinga di dalam form
React menyediakan hook useFormStatus yang membaca status form terdekat. Ada satu aturan keras: hook ini hanya boleh dipakai di komponen yang di-render di dalam <form action={...}>. Kalau kamu memakainya langsung di komponen page yang me-render <form>-nya sendiri, hook itu tuli, ia tidak tahu form mana yang dimaksud. Makanya pola standarnya adalah komponen tombol terpisah:
// app/kontak/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Mengirim..." : "Kirim Pesan"}
</button>
);
}Perhatikan "use client" di baris pertama. useFormStatus adalah hook, dan hook hanya hidup di Client Component. File action dan page-nya sendiri bisa tetap Server Component, hanya tombol kecil ini yang jadi client.
Action yang jujur: kembalikan hasil
Server Action yang bagus tidak diam saja, ia mengembalikan hasil yang bisa dibaca client. Di sini kita juga menambahkan validasi server (lihat modul server-actions-validasi) dan simulasi jeda agar efek pending terlihat jelas:
// app/kontak/actions.ts
"use server";
import { z } from "zod";
const Skema = z.object({
nama: z.string().min(2, "Nama minimal 2 karakter"),
pesan: z.string().min(10, "Pesan minimal 10 karakter"),
});
export async function kirimPesan(formData: FormData) {
const data = Skema.safeParse({
nama: formData.get("nama"),
pesan: formData.get("pesan"),
});
if (!data.success) {
return { ok: false as const, error: data.error.issues[0].message };
}
await new Promise((r) => setTimeout(r, 1500)); // simulasi server lambat
await simpanKeDatabase(data.data);
return { ok: true as const };
}Return value harus serializable (object, string, boolean). Jangan me-return Date, Map, atau instance class karena data ini dikirim lewat network sebagai JSON.
Menampilkan hasil dan me-reset form
Terakhir, bungkus form dalam Client Component kecil yang menangkap hasil action, menampilkan pesan, dan me-reset form setelah sukses:
// app/kontak/Form.tsx
"use client";
import { useRef, useState } from "react";
import { kirimPesan } from "./actions";
import { SubmitButton } from "./SubmitButton";
export function Form() {
const formRef = useRef<HTMLFormElement>(null);
const [status, setStatus] = useState<string | null>(null);
return (
<form
ref={formRef}
action={async (formData) => {
const hasil = await kirimPesan(formData);
if (hasil.ok) {
formRef.current?.reset(); // kosongkan form setelah sukses
setStatus("Pesan terkirim, terima kasih!");
} else {
setStatus(hasil.error);
}
}}
>
<input name="nama" placeholder="Nama" />
<textarea name="pesan" placeholder="Tulis pesanmu..." />
{status && <p>{status}</p>}
<SubmitButton />
</form>
);
}Satu trade-off yang perlu kamu tahu: karena action dipanggil lewat fungsi inline di client, form ini butuh JavaScript untuk menampilkan pesan hasilnya. Kalau kamu butuh form yang tetap berfungsi tanpa JS (progressive enhancement murni), tampilkan pesan sukses di halaman hasil terpisah setelah redirect(), pola yang dibahas di modul revalidatepath-redirect.
Catatan teknis: Jangan pernah mengandalkan
disableddi sisi client sebagai satu-satunya penjaga. User yang nakal bisa menghapus atributdisabledlewat DevTools dan submit berkali-kali. Validasi dan idempotency tetap harus dijaga di dalam Server Action.
Tantangan
Tombol pintar
Buat form kontak lengkap dengan SubmitButton yang disable + berubah teks saat pending. Simulasikan action lambat (delay 2 detik) untuk melihat efeknya.