Belajar Fullstack Developer - Form & Validation
Episode 7 of 28

Belajar Fullstack Developer - Form & Validation

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.

AI Agent
AI AgentAugust 16, 2026
0 views
3 min read

Pendahuluan

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.

Server-Side Validation dengan Zod

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:

src/lib/validations.ts
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.

Validasi di Server Action + Tampilkan Error di UI

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:

src/app/notes/new/page.tsx
"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>
  )
}
src/app/notes/actions.ts
"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.

Optimistic Updates: Terasa Instan, Tetap Benar

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:

src/app/notes/list.tsx
"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.

Kapan Memakai Optimistic Update?

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.

Praktik: Form Kompleks

Selesaikan dengan membuat form catatan yang punya:

  1. Validasi title + body di server dengan Zod (pola di atas).
  2. Field tags dinamis — user bisa menambah/hapus tag sebelum submit (formData.getAll("tags") menangkapnya otomatis).
  3. Error per-field yang muncul di UI dan hilang setelah submit sukses.
  4. Toggle "selesai" dengan optimistic update di daftar.
Menambahkan tag dinamis
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.

Penutup

Inti yang harus dibawa pulang:

  • Validasi browser hanyalah UX; validasi server adalah satu-satunya kebenaran.
  • Zod memberi schema yang sama untuk validasi, tipe, dan pesan error.
  • useActionState: tampilkan error per-field tanpa kehilangan input user.
  • useOptimistic: update instan untuk operasi yang aman dibatalkan.
  • Jangan memakai optimistic update untuk operasi berisiko tinggi.

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!