Artikel ini disponsori oleh SurveyJS Ada model mental yang dibagikan oleh sebagian besar pengembang React tanpa pernah mendiskusikannya secara terbuka. Bentuk-bentuk itu selalu dianggap sebagai komponen. Ini berarti tumpukan seperti:

React Hook Form untuk negara bagian lokal (render ulang minimal, registrasi lapangan ergonomis, interaksi penting). Zod untuk validasi (kebenaran input, validasi batas, penguraian aman tipe). React Query untuk backend: pengiriman, percobaan ulang, caching, sinkronisasi server, dan sebagainya.

Dan untuk sebagian besar formulir — layar login Anda, halaman pengaturan Anda, modal CRUD Anda — ini bekerja dengan sangat baik. Setiap bagian melakukan tugasnya, disusun dengan rapi, dan Anda dapat beralih ke bagian aplikasi yang benar-benar membedakan produk Anda. Namun sesekali, formulir mulai mengumpulkan hal-hal seperti aturan visibilitas yang bergantung pada jawaban sebelumnya, atau nilai turunan yang mengalir melalui tiga bidang. Bahkan mungkin seluruh halaman yang harus dilewati atau ditampilkan berdasarkan total berjalan. Anda menangani kondisi pertama dengan useWatch dan cabang inline, dan itu tidak masalah. Lalu yang lain. Kemudian Anda menggunakan superRefine untuk menyandikan aturan lintas bidang yang tidak dapat diungkapkan oleh skema Zod Anda dengan cara biasa. Kemudian, navigasi langkah mulai membocorkan logika bisnis. Pada titik tertentu, Anda melihat apa yang telah Anda buat dan menyadari bahwa formulir tersebut bukan lagi UI. Ini lebih merupakan proses pengambilan keputusan, dan pohon komponen adalah tempat Anda menyimpannya. Di sinilah menurut saya model mental untuk formulir di React rusak, dan ini sebenarnya bukan salah siapa pun. Tumpukan RHF + Zod sangat bagus dalam hal desainnya. Masalahnya adalah kita cenderung terus menggunakannya melewati titik di mana abstraksinya sesuai dengan permasalahan karena alternatifnya memerlukan cara berpikir yang berbeda tentang bentuk secara keseluruhan. Artikel ini tentang alternatif itu. Untuk menunjukkan hal ini, kami akan membuat formulir multi-langkah yang sama persis sebanyak dua kali:

Dengan React Hook Form + Zod yang dihubungkan ke React Query untuk pengiriman, Dengan SurveyJS, yang memperlakukan formulir sebagai data — skema JSON sederhana — dan bukan pohon komponen.

Persyaratan yang sama, logika kondisional yang sama, panggilan API yang sama di akhir. Kemudian kita akan memetakan dengan tepat apa yang berpindah dan apa yang tersisa, serta memaparkan cara praktis untuk memutuskan model mana yang harus Anda gunakan, dan kapan. Formulir yang kami buat:

Formulir ini akan menggunakan alur 4 langkah: Langkah 1: Detail

Nama depan (wajib), Email (wajib, format valid).

Langkah 2: Pesan

Harga satuan, Kuantitas, tarif pajak, Berasal: Subtotal, Pajak, Jumlah.

Langkah 3: Akun & Umpan Balik

Apakah Anda punya akun? (Ya/Tidak) Jika Ya → nama pengguna + kata sandi, keduanya diperlukan. Jika Tidak → email sudah dikumpulkan di langkah 1.

Peringkat kepuasan (1–5) Jika ≥ 4 → tanyakan “Apa yang kamu suka?” Jika ≤ 2 → tanyakan “Apa yang bisa kita tingkatkan?”

Langkah 4: Tinjau

Hanya muncul jika total >= 100 Penyerahan terakhir.

Ini tidak ekstrem. Tapi itu cukup untuk mengungkap perbedaan arsitektural. Bagian 1: Berbasis Komponen (React Hook Form + Zod) Instalasi npm instal react-hook-form zod @hookform/resolver @tanstack/react-query

Skema Zod Mari kita mulai dengan skema Zod, karena biasanya di sinilah bentuk terbentuk. Untuk dua langkah pertama — detail pribadi dan masukan pesanan — semuanya mudah: string yang diperlukan, angka dengan minimum, dan enum. Bagian yang menarik dimulai ketika Anda mencoba mengungkapkan aturan bersyarat.

impor { z } dari "zod";

ekspor const formSchema = z.object({ firstName: z.string().min(1, "Wajib"), email: z.string().email("Email tidak valid"), harga: z.number().min(0), jumlah: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(["Yes", "No"]), nama pengguna: z.string().optional(), kata sandi: z.string().optional(), kepuasan: z.number().min(1).max(5), positifUmpan Balik: z.string().optional(), perbaikanUmpan Balik: z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount === "Yes") { if (!data.username) { ctx.addIssue({ kode: "custom", jalur: ["nama pengguna"], pesan: "Wajib" } } if (!data.password || data.password.length < 6) { ctx.addIssue({ kode: "custom", path: ["password"], pesan: "Minimal 6 karakter" });

if (data.satisfaction >= 4 && !data.patientFeedback) { ctx.addIssue({ code: "custom", path: ["positifFeedback"], pesan: "Silakan bagikan apa yang Anda suka" }); }

if (data.kepuasan <= 2 && !data.improvementFeedback) { ctx.addIssue({ kode: "custom", jalur:["improvementFeedback"], pesan: "Tolong beri tahu kami apa yang perlu ditingkatkan" }); }});

tipe ekspor FormData = z.infer;

Perhatikan bahwa nama pengguna dan kata sandi diketik sebagai opsional() meskipun diperlukan secara kondisional karena skema tingkat tipe Zod menjelaskan bentuk objek, bukan aturan yang mengatur kapan bidang itu penting. Persyaratan kondisional harus ada di dalam superRefine, yang berjalan setelah bentuk divalidasi dan memiliki akses ke objek penuh. Pemisahan itu bukanlah sebuah cacat; itulah tujuan alat ini dirancang: superRefine adalah tempat logika lintas bidang digunakan ketika logika tersebut tidak dapat diekspresikan dalam struktur skema itu sendiri. Yang juga penting di sini adalah apa yang tidak diungkapkan oleh skema ini. Ia tidak memiliki konsep halaman, tidak ada konsep bidang mana yang terlihat pada titik mana, dan tidak ada konsep navigasi. Semua itu akan tinggal di tempat lain. Komponen Formulir

import { useForm, useWatch } dari "react-hook-form";import { zodResolver } dari "@hookform/resolvers/zod";import { useMutation } dari "@tanstack/react-query";import { useState, useMemo } dari "react";import { formSchema, ketik FormData } dari "./schema";

const LANGKAH = ["detail", "pesanan", "akun", "ulasan"];

ketik OrderPayload = FormData & { subtotal: nomor; pajak: nomor; jumlah: angka };

fungsi ekspor RHFMultiStepForm() { const [langkah, setStep] = useState(0);

const mutasi = useMutation({ mutasiFn: async (muatan: OrderPayload) => { const res = menunggu pengambilan("/api/pesanan", { metode: "POSTING", header: { "Tipe Konten": "application/json" }, isi: JSON.stringify(muatan), }); if (!res.ok) throw new Error("Gagal mengirimkan"); kembalikan res.json(); }, });

const { register, control, handleSubmit, formState: { error }, } = useForm({ Resolver: zodResolver(formSchema), defaultValues: { harga: 0, kuantitas: 1, Tarif pajak: 0,1, kepuasan: 3, hasAccount: "No", }, }); const harga = useWatch({ kontrol, nama: "harga" }); kuantitas const = useWatch({ kontrol, nama: "kuantitas" }); const taxRate = useWatch({ kontrol, nama: "taxRate" }); const hasAccount = useWatch({ kontrol, nama: "hasAccount" }); const kepuasan = useWatch({ kontrol, nama: "kepuasan" }); const subtotal = useMemo(() => (harga ?? 0) * (jumlah ?? 1), [harga, kuantitas]); const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); const total = useMemo(() => subtotal + pajak, [subtotal, pajak]); const onSubmit = (data: FormData) => mutasi.mutate({ ...data, subtotal, pajak, total }); const showSubmit = (langkah === 2 && total < 100) || (langkah === 3 && total >= 100)

return (

{langkah === 0 && ( <> )}

{langkah === 1 && ( <>

Subtotal: {subtotal}
Pajak: {tax}
Total: {total}
)}

{langkah === 2 && ( <>

{hasAccount === "Ya" && ( <> )}

{kepuasan >= 4 && (