Membangun form kompleks dengan server-side validation menggunakan Zod, menampilkan error di UI dengan useActionState, dan menerapkan optimistic updates agar aplikasi terasa responsif tanpa mengorbankan kebenaran data.

Form adalah tempat data masuk ke sistem — dan tempat paling banyak bug serta serangan terjadi. Pada episode 5 kita memakai Zod untuk memvalidasi payload API; pada episode ini kita menerapkannya penuh untuk form kompleks: validasi di server (sumber kebenaran), error handling yang muncul di UI, dan optimistic updates agar aplikasi terasa instan.
Mengapa validasi harus di server, bukan hanya di browser? Karena validasi browser (HTML required, pattern) hanyalah kenyamanan UX — bisa dilewati penyerang dengan request langsung ke server. Server adalah satu-satunya tempat validasi yang bisa dipercaya.
Zod (sudah kita kenal di episode 5) ideal untuk form karena ia menghasilkan typed schema — data yang lolos validasi otomatis punya tipe TypeScript yang benar:
import { z } from "zod"
export const noteSchema = z.object({
title: z.string().trim().min(1, "Judul wajib diisi").max(120, "Maksimal 120 karakter"),
body: z.string().trim().min(1, "Isi wajib diisi").max(10000),
tags: z.array(z.string().min(1).max(30)).max(5, "Maksimal 5 tag"),
})
export type NoteInput = z.infer<typeof noteSchema>Perhatikan dua detail: .trim() membersihkan spasi kosong, dan pesan error disematkan langsung di schema — satu sumber kebenaran untuk validasi.
Di episode 3-4, server action kita mengabaikan hasil validasi. Sekarang kita perbaiki dengan pola yang benar. Server action mengembalikan hasil validasi, dan UI menampilkannya:
"use client"
import { useActionState } from "react"
import { createNote } from "@/app/notes/actions"
export default function NewNote() {
const [state, action, pending] = useActionState(createNote, null)
return (
<form action={action}>
<input name="title" defaultValue={state?.fields?.title ?? ""} />
{state?.errors?.title && (
<p className="text-red-500">{state.errors.title}</p>
)}
<textarea name="body" defaultValue={state?.fields?.body ?? ""} />
{state?.errors?.body && (
<p className="text-red-500">{state.errors.body}</p>
)}
<button disabled={pending}>
{pending ? "Menyimpan..." : "Simpan"}
</button>
</form>
)
}"use server"
import { prisma } from "@/lib/prisma"
import { noteSchema } from "@/lib/validations"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
export type NoteState = {
errors?: Partial<Record<keyof typeof noteSchema.shape, string>>
fields?: Record<string, string>
} | null
export async function createNote(
_prev: NoteState,
formData: FormData,
): Promise<NoteState> {
const result = noteSchema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
tags: formData.getAll("tags"),
})
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
fields: {
title: String(formData.get("title") ?? ""),
body: String(formData.get("body") ?? ""),
},
}
}
await prisma.note.create({ data: result.data })
revalidatePath("/notes")
redirect("/notes")
}useActionState memberi tiga hal: state (hasil server action), action (fungsi yang dipanggil form), dan pending (status loading). Pola ini menyimpan input yang gagal (fields) agar user tidak kehilangan ketikan — pengalaman yang terasa "dewasa".
Note
Perhatikan: result.data sudah bertipe NoteInput — setelah lolos Zod, data langsung aman dikirim ke Prisma tanpa casting. Inilah yang dimaksud "satu sumber kebenaran validasi": schema Zod dipakai validasi, tipe, dan (jika mau) juga untuk input UI.
Ketika data yang diubah pasti berhasil (misalnya menandai catatan selesai), menunggu server terasa lambat. Optimistic update menampilkan hasil di UI sebelum server merespons, lalu membetulkan jika gagal. React menyediakan useOptimistic:
"use client"
import { useOptimistic } from "react"
import { toggleNote } from "@/app/notes/actions"
type Note = { id: string; title: string; done: boolean }
export default function NoteList({ notes }: { notes: Note[] }) {
const [optimisticNotes, toggleOptimistic] = useOptimistic(
notes,
(current, id: string) =>
current.map((n) =>
n.id === id ? { ...n, done: !n.done } : n,
),
)
return (
<ul>
{optimisticNotes.map((note) => (
<li key={note.id}>
<label>
<input
type="checkbox"
checked={note.done}
onChange={() => {
toggleOptimistic(note.id)
toggleNote(note.id)
}}
/>
{note.title}
</label>
</li>
))}
</ul>
)
}Tampilan langsung berubah saat kotak dicentang; server action berjalan di belakang. Jika gagal, server action mengembalikan error dan UI bisa dikembalikan.
Warning
Optimistic update hanya untuk operasi yang hampir pasti berhasil dan bisa dibatalkan — toggle, like, update label. Jangan dipakai untuk operasi berisiko tinggi seperti pembayaran atau submit yang punya validasi kompleks — di sana hasil server harus menjadi satu-satunya kebenaran.
Selesaikan dengan membuat form catatan yang punya:
formData.getAll("tags") menangkapnya otomatis).const [tags, setTags] = useState<string[]>([])
// setTags([...tags, "baru"]) saat tombol tambah diklik
// tags.map((t) => <input type="hidden" name="tags" value={t} key={t} />)Uji dengan sengaja mengosongkan judul lalu submit — error harus muncul tepat di bawah field, dan ketikan kalian tidak hilang.
Inti yang harus dibawa pulang:
useActionState: tampilkan error per-field tanpa kehilangan input user.useOptimistic: update instan untuk operasi yang aman dibatalkan.Di episode 8 selanjutnya kita membahas state & data fetching — client vs server state, caching, dan revalidation — agar data di aplikasi selalu cepat dan selalu segar. Sampai jumpa di episode 8!