Tuesday, May 26, 2026Tech HubAboutContactAdvertiseNewsletter
Back to Home
Building Dynamic Forms In React And Next.js

Building Dynamic Forms In React And Next.js

Some forms stay UI, while others quietly become rule engines. Here’s why these two different approaches exist and how to choose between them.

B
Blizine Admin
·1 min read·0 views
Skip to main content Start reading the article Jump to list of all articles Jump to all topics14 min readJavaScript, React, CodingShare on Twitter, LinkedInAbout The AuthorSunil is an experienced software engineer and tech entrepreneur with a strong focus on building scalable web applications. More about Sunil ↬Email NewsletterYour (smashing) email Weekly tips on front-end & UX.Trusted by 182,000+ folks.Some forms stay UI, while others quietly become rule engines. Here’s why these two different approaches exist and how to choose between them.This article has been kindly supported by our dear friends at SurveyJS, JavaScript UI libraries for custom web forms. Keep full ownership of your data. Build JSON-driven forms in your app (React/Angular/Vue) without SaaS limitations. Thank you!There’s a mental model most React developers share without ever discussing it out loud. That forms are always supposed to be components. This means a stack like:React Hook Form for local state (minimal re-renders, ergonomic field registration, imperative interaction).Zod for validation (input correctness, boundary validation, type-safe parsing).React Query for backend: submission, retries, caching, server sync, and so on.And for the vast majority of forms — your login screens, your settings pages, your CRUD modals — this works really well. Each piece does its job, they compose cleanly, and you can move on to the parts of your application that actually differentiate your product.But every once in a while, a form starts accumulating things like visibility rules that depend on earlier answers, or derived values that cascade through three fields. Maybe even entire pages that should be skipped or shown based on a running total.You handle the first conditional with a useWatch and an inline branch, which is fine. Then another. Then you’re reaching for superRefine to encode cross-field rules that your Zod schema can’t express in the normal way. Then, step navigation starts leaking business logic. At some point, you look at what you’ve built and realize that the form isn’t really UI anymore. It’s more of a decision process, and the component tree is just where you happened to store it.This is where I think the mental model for forms in React breaks down, and it’s really nobody’s fault. The RHF + Zod stack is excellent at what it was designed for. The issue is that we tend to keep using it past the point where its abstractions match the problem because the alternative requires a different way of thinking about forms entirely.This article is about that alternative. To show this, we’ll build the exact same multi-step form twice:With React Hook Form + Zod wired to React Query for submission,With SurveyJS, which treats a form as data — a simple JSON schema — rather than a component tree.Same requirements, same conditional logic, same API call at the end. Then we’ll map exactly what moved and what stayed, and lay out a practical way to decide which model you should use, and when.The form we’re building:(Large preview)This form will use a 4-step flow:Step 1: DetailsFirst name (required),Email (required, valid format).Step 2: OrderUnit price,Quantity,Tax rate,Derived:Subtotal,Tax,Total.Step 3: Account & FeedbackDo you have an account? (Yes/No)If Yes → username + password, both required.If No → email already collected in step 1.Satisfaction rating (1–5)If ≥ 4 → ask “What did you like?”If ≤ 2 → ask “What can we improve?”Step 4: ReviewOnly appears if total >= 100Final submission.This is not extreme. But it’s enough to expose architectural differences.Part 1: Component-Driven (React Hook Form + Zod)Installationnpm install react-hook-form zod @hookform/resolvers @tanstack/react-query Zod SchemaLet’s start with the Zod schema, because that’s usually where the shape of the form gets established. For the first two steps — personal details and order inputs — everything is straightforward: required strings, numbers with minimums, and an enum. The interesting part starts when you try to express the conditional rules.import { z } from "zod"; export const formSchema = z.object({ firstName: z.string().min(1, "Required"), email: z.string().email("Invalid email"), price: z.number().min(0), quantity: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(["Yes", "No"]), username: z.string().optional(), password: z.string().optional(), satisfaction: z.number().min(1).max(5), positiveFeedback: z.string().optional(), improvementFeedback: z.string().optional(), }).superRefine((data, ctx) => { if (data.hasAccount === "Yes") { if (!data.username) { ctx.addIssue({ code: "custom", path: ["username"], message: "Required" }); } if (!data.password || data.password.length = 4 && !data.positiveFeedback) { ctx.addIssue({ code: "custom", path: ["positiveFeedback"], message: "Please share what you liked" }); } if (data.satisfaction ; Notice that username and password are typed as optional() even though they’re conditionally required because Zod’s type-level schema describes the shape of the object, not the rules governing when fields matter.The conditional requirement has to live inside superRefine, which runs after the shape is validated and has access to the full object. That separation is not a flaw; it’s just what the tool is designed for: superRefine is where cross-field logic goes when it can’t be expressed in the schema structure itself.What’s also notable here is what this schema doesn’t express. It has no concept of pages, no concept of which fields are visible at which point, and no concept of navigation. All of that will live somewhere else.Form Componentimport { useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; import { useState, useMemo } from "react"; import { formSchema, type FormData } from "./schema"; const STEPS = ["details", "order", "account", "review"]; type OrderPayload = FormData & { subtotal: number; tax: number; total: number }; export function RHFMultiStepForm() { const [step, setStep] = useState(0); const mutation = useMutation({ mutationFn: async (payload: OrderPayload) => { const res = await fetch("/api/orders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error("Failed to submit"); return res.json(); }, }); const { register, control, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(formSchema), defaultValues: { price: 0, quantity: 1, taxRate: 0.1, satisfaction: 3, hasAccount: "No", }, }); const price = useWatch({ control, name: "price" }); const quantity = useWatch({ control, name: "quantity" }); const taxRate = useWatch({ control, name: "taxRate" }); const hasAccount = useWatch({ control, name: "hasAccount" }); const satisfaction = useWatch({ control, name: "satisfaction" }); const subtotal = useMemo(() => (price ?? 0) * (quantity ?? 1), [price, quantity]); const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); const total = useMemo(() => subtotal + tax, [subtotal, tax]); const onSubmit = (data: FormData) => mutation.mutate({ ...data, subtotal, tax, total }); const showSubmit = (step === 2 && total = 100) return ( {step === 0 && ( )} {step === 1 && ( 5% 10% 15% Subtotal: {subtotal} Tax: {tax} Total: {total} )} {step === 2 && ( Yes No {hasAccount === "Yes" && ( )} {satisfaction >= 4 && ( )} {satisfaction )} )} {step === 3 && total >= 100 && Review and submit} {step > 0 && setStep(step - 1)}>Back} {showSubmit ? ( {mutation.isPending ? "Submitting…" : "Submit"} ) : step < STEPS.length - 1 ? (

📰Originally published at smashingmagazine.com

Comments