{"id":1170,"date":"2026-03-10T12:00:00","date_gmt":"2026-03-10T13:00:00","guid":{"rendered":"https:\/\/computercoursesonline.com\/?p=1170"},"modified":"2026-03-18T10:00:37","modified_gmt":"2026-03-18T10:00:37","slug":"building-dynamic-forms-in-react-and-next-js","status":"publish","type":"post","link":"https:\/\/computercoursesonline.com\/index.php\/2026\/03\/10\/building-dynamic-forms-in-react-and-next-js\/","title":{"rendered":"Building Dynamic Forms In React And Next.js"},"content":{"rendered":"

Building Dynamic Forms In React And Next.js<\/title><\/p>\n<article>\n<header>\n<h1>Building Dynamic Forms In React And Next.js<\/h1>\n<address>Sunil Sandhu<\/address>\n<p> 2026-03-10T13:00:00+00:00<br \/>\n 2026-03-18T09:33:12+00:00<br \/>\n <\/header>\n<p>This article is sponsored by <b>SurveyJS<\/b><\/p>\n<p>There\u2019s a mental model most React developers share without ever discussing it out loud. That forms are <em>always<\/em> supposed to be components. This means a stack like:<\/p>\n<ul>\n<li><strong>React Hook Form<\/strong> for local state (minimal re-renders, ergonomic field registration, imperative interaction).<\/li>\n<li><strong>Zod<\/strong> for validation (input correctness, boundary validation, type-safe parsing).<\/li>\n<li><strong>React Query<\/strong> for backend: submission, retries, caching, server sync, and so on.<\/li>\n<\/ul>\n<p>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.<\/p>\n<p>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.<\/p>\n<p>You handle the first conditional with a <code>useWatch<\/code> and an inline branch, which is fine. Then another. <a href=\"https:\/\/zod.dev\/api#superrefine\">Then you\u2019re reaching for <code>superRefine<\/code><\/a> to encode cross-field rules that your Zod schema can\u2019t express in the normal way. Then, step navigation starts leaking business logic. At some point, you look at what you\u2019ve built and realize that the form isn\u2019t really UI anymore. It\u2019s more of a decision process, and the component tree is just where you happened to store it.<\/p>\n<p>This is where I think the mental model for forms in React breaks down, and it\u2019s really nobody\u2019s fault. The RHF + Zod stack is excellent at what it was designed for. <strong>The issue is that we tend to keep using it past the point where its abstractions match the problem<\/strong> because the alternative requires a different way of thinking about forms entirely.<\/p>\n<p>This article is about that alternative. To show this, we\u2019ll build the exact same multi-step form twice:<\/p>\n<ol>\n<li>With React Hook Form + Zod wired to React Query for submission,<\/li>\n<li>With SurveyJS, which treats a form as data — a simple JSON schema — rather than a component tree.<\/li>\n<\/ol>\n<p>Same requirements, same conditional logic, same API call at the end. Then we\u2019ll map exactly what moved and what stayed, and lay out a practical way to decide which model you should use, and when.<\/p>\n<p><strong>The form we\u2019re building:<\/strong><\/p>\n<figure class=\"\n \n break-out article__image\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/files.smashing.media\/articles\/building-dynamic-forms-react-next-js\/1-dynamic-form.png\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"798\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Multi-step dynamic form\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/building-dynamic-forms-react-next-js\/1-dynamic-form.png\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n (<a href=\"https:\/\/files.smashing.media\/articles\/building-dynamic-forms-react-next-js\/1-dynamic-form.png\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<p>This form will use a 4-step flow:<\/p>\n<p><strong>Step 1: Details<\/strong><\/p>\n<ul>\n<li>First name (required),<\/li>\n<li>Email (required, valid format).<\/li>\n<\/ul>\n<p><strong>Step 2: Order<\/strong><\/p>\n<ul>\n<li>Unit price,<\/li>\n<li>Quantity,<\/li>\n<li>Tax rate,<\/li>\n<li>Derived:\n<ul>\n<li>Subtotal,<\/li>\n<li>Tax,<\/li>\n<li>Total.<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<p><strong>Step 3: Account & Feedback<\/strong><\/p>\n<ul>\n<li>Do you have an account? (Yes\/No)\n<ul>\n<li>If Yes \u2192 username + password, both required.<\/li>\n<li>If No \u2192 email already collected in step 1.<\/li>\n<\/ul>\n<\/li>\n<li>Satisfaction rating (1\u20135)\n<ul>\n<li>If \u2265 4 \u2192 ask \u201cWhat did you like?\u201d<\/li>\n<li>If \u2264 2 \u2192 ask \u201cWhat can we improve?\u201d<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n<p><strong>Step 4: Review<\/strong><\/p>\n<ul>\n<li>Only appears if <code>total >= 100<\/code><\/li>\n<li>Final submission.<\/li>\n<\/ul>\n<p>This is not extreme. But it\u2019s enough to expose architectural differences.<\/p>\n<h2 id=\"part-1-component-driven-react-hook-form-zod\">Part 1: Component-Driven (React Hook Form + Zod)<\/h2>\n<h3 id=\"installation\">Installation<\/h3>\n<pre><code class=\"language-bash\">npm install react-hook-form zod @hookform\/resolvers @tanstack\/react-query\n<\/code><\/pre>\n<h3 id=\"zod-schema\">Zod Schema<\/h3>\n<p>Let\u2019s start with the Zod schema, because that\u2019s 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.<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-typescript\">import { z } from \"zod\";\n\nexport const formSchema = z.object({ \n firstName: z.string().min(1, \"Required\"), \n email: z.string().email(\"Invalid email\"), \n price: z.number().min(0), \n quantity: z.number().min(1), \n taxRate: z.number(), \n hasAccount: z.enum([\"Yes\", \"No\"]), \n username: z.string().optional(), \n password: z.string().optional(), \n satisfaction: z.number().min(1).max(5), \n positiveFeedback: z.string().optional(), \n improvementFeedback: z.string().optional(), \n}).superRefine((data, ctx) => { \n if (data.hasAccount === \"Yes\") { \n if (!data.username) { \n ctx.addIssue({ code: \"custom\", path: [\"username\"], message: \"Required\" }); \n } \n if (!data.password || data.password.length < 6) { \n ctx.addIssue({ code: \"custom\", path: [\"password\"], message: \"Min 6 characters\" }); \n } \n }\n\n if (data.satisfaction >= 4 && !data.positiveFeedback) { \n ctx.addIssue({ code: \"custom\", path: [\"positiveFeedback\"], message: \"Please share what you liked\" }); \n }\n\n if (data.satisfaction <= 2 && !data.improvementFeedback) { \n ctx.addIssue({ code: \"custom\", path: [\"improvementFeedback\"], message: \"Please tell us what to improve\" }); \n } \n});\n\nexport type FormData = z.infer<typeof formSchema>;\n<\/code><\/pre>\n<\/div>\n<p>Notice that <code>username<\/code> and <code>password<\/code> are typed as <code>optional()<\/code> even though they\u2019re conditionally required because Zod\u2019s type-level schema describes the <em>shape<\/em> of the object, not the rules governing when fields matter.<\/p>\n<p>The conditional requirement has to live inside <code>superRefine<\/code>, which runs after the shape is validated and has access to the full object. That separation is not a flaw; it\u2019s just what the tool is designed for: <code>superRefine<\/code> is where cross-field logic goes when it can\u2019t be expressed in the schema structure itself.<\/p>\n<p>What\u2019s also notable here is what this schema <em>doesn\u2019t<\/em> 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.<\/p>\n<h3 id=\"form-component\">Form Component<\/h3>\n<div class=\"break-out\">\n<pre><code class=\"language-typescript\">import { useForm, useWatch } from \"react-hook-form\"; \nimport { zodResolver } from \"@hookform\/resolvers\/zod\"; \nimport { useMutation } from \"@tanstack\/react-query\"; \nimport { useState, useMemo } from \"react\"; \nimport { formSchema, type FormData } from \".\/schema\";\n\nconst STEPS = [\"details\", \"order\", \"account\", \"review\"];\n\ntype OrderPayload = FormData & { subtotal: number; tax: number; total: number };\n\nexport function RHFMultiStepForm() { \n const [step, setStep] = useState(0);\n\n const mutation = useMutation({\n mutationFn: async (payload: OrderPayload) => {\n const res = await fetch(\"\/api\/orders\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application\/json\" },\n body: JSON.stringify(payload),\n });\n if (!res.ok) throw new Error(\"Failed to submit\");\n return res.json();\n },\n });\n\n const { \n register, \n control, \n handleSubmit, \n formState: { errors }, \n } = useForm<FormData>({ \n resolver: zodResolver(formSchema), \n defaultValues: { \n price: 0, \n quantity: 1, \n taxRate: 0.1, \n satisfaction: 3, \n hasAccount: \"No\", \n }, \n }); \n\n const price = useWatch({ control, name: \"price\" }); \n const quantity = useWatch({ control, name: \"quantity\" }); \n const taxRate = useWatch({ control, name: \"taxRate\" }); \n const hasAccount = useWatch({ control, name: \"hasAccount\" }); \n const satisfaction = useWatch({ control, name: \"satisfaction\" }); \n\n const subtotal = useMemo(() => (price ?? 0) * (quantity ?? 1), [price, quantity]); \n const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); \n const total = useMemo(() => subtotal + tax, [subtotal, tax]); \n\n const onSubmit = (data: FormData) => mutation.mutate({ ...data, subtotal, tax, total }); \n\n const showSubmit = (step === 2 && total < 100) || (step === 3 && total >= 100)\n\n return ( \n <form onSubmit={handleSubmit(onSubmit)}> \n {step === 0 && ( \n <> \n <input {...register(\"firstName\")} placeholder=\"First Name\" \/> \n <input {...register(\"email\")} placeholder=\"Email\" \/> \n <\/> \n )}\n\n {step === 1 && ( \n <> \n <input type=\"number\" {...register(\"price\", { valueAsNumber: true })} \/> \n <input type=\"number\" {...register(\"quantity\", { valueAsNumber: true })} \/> \n <select {...register(\"taxRate\", { valueAsNumber: true })}> \n <option value=\"0.05\">5%<\/option> \n <option value=\"0.1\">10%<\/option> \n <option value=\"0.15\">15%<\/option> \n <\/select>\n\n <div>Subtotal: {subtotal}<\/div> \n <div>Tax: {tax}<\/div> \n <div>Total: {total}<\/div> \n <\/> \n )}\n\n {step === 2 && ( \n <> \n <select {...register(\"hasAccount\")}> \n <option value=\"Yes\">Yes<\/option> \n <option value=\"No\">No<\/option> \n <\/select>\n\n {hasAccount === \"Yes\" && ( \n <> \n <input {...register(\"username\")} placeholder=\"Username\" \/> \n <input {...register(\"password\")} placeholder=\"Password\" \/> \n <\/> \n )}\n\n <input type=\"number\" {...register(\"satisfaction\", { valueAsNumber: true })} \/>\n\n {satisfaction >= 4 && ( \n <textarea {...register(\"positiveFeedback\")} \/> \n )}\n\n {satisfaction <= 2 && ( \n <textarea {...register(\"improvementFeedback\")} \/> \n )} \n <\/> \n )}\n\n {step === 3 && total >= 100 && <div>Review and submit<\/div>}\n\n <div> \n {step > 0 && <button type=\"button\" onClick={() => setStep(step - 1)}>Back<\/button>} \n {showSubmit ? ( \n <button type=\"submit\" disabled={mutation.isPending}> \n {mutation.isPending ? \"Submitting\u2026\" : \"Submit\"} \n <\/button> \n ) : step < STEPS.length - 1 ? ( \n <button type=\"button\" onClick={() => setStep(step + 1)}>Next<\/button> \n ) : null} \n <\/div> \n {mutation.isError && <div>Error: {mutation.error.message}<\/div>} \n <\/form> \n ); \n}\n<\/code><\/pre>\n<\/div>\n<figure class=\"break-out\">\n<p data-height=\"480\" data-theme-id=\"light\" data-slug-hash=\"gbwwmNO\" data-user=\"smashingmag\" data-default-tab=\"result\" class=\"codepen\">See the Pen [SurveyJS-03-RHF [forked]](https:\/\/codepen.io\/smashingmag\/pen\/gbwwmNO) by <a href=\"https:\/\/codepen.io\/sixthextinction\">sixthextinction<\/a>.<\/p><figcaption>See the Pen <a href=\"https:\/\/codepen.io\/smashingmag\/pen\/gbwwmNO\">SurveyJS-03-RHF [forked]<\/a> by <a href=\"https:\/\/codepen.io\/sixthextinction\">sixthextinction<\/a>.<\/figcaption><\/figure>\n<p>There\u2019s quite a lot happening here, and it\u2019s worth slowing down to notice where things ended up.<\/p>\n<ul>\n<li>The derived values — <code>subtotal<\/code>, <code>tax<\/code>, <code>total<\/code> — are computed in the component via <code>useWatch<\/code> and <code>useMemo<\/code> because they depend on live field values and there\u2019s no other natural place for them.<\/li>\n<li>The visibility rules for <code>username<\/code>, <code>password<\/code>, <code>positiveFeedback<\/code>, and <code>improvementFeedback<\/code> live in JSX as inline conditionals.<\/li>\n<li>The step-skipping logic — the review page only appearing when <code>total >= 100<\/code> — is embedded into the <code>showSubmit<\/code> variable and the render condition on step 3.<\/li>\n<li>Navigation itself is just a <code>useState<\/code> counter that we\u2019re manually incrementing.<\/li>\n<li>React Query handles retries, caching, and invalidation. The form just calls <code>mutation.mutate<\/code> with validated data.<\/li>\n<\/ul>\n<p>None of this is <em>wrong,<\/em> per se. This is still idiomatic React, and the component is quite performant thanks to how RHF isolates re-renders.<\/p>\n<p>But if you were to hand this to someone who hadn\u2019t written it and ask them to explain <em>under what conditions the review page appears<\/em>, they\u2019d have to trace through <code>showSubmit<\/code>, the step 3 render condition, and the nav button logic — three separate places — to reconstruct a rule that could have been stated in one line.<\/p>\n<p><strong>The form works, yes, but the behavior isn\u2019t really inspectable as a system.<\/strong> It has to be executed mentally.<\/p>\n<p>More importantly, changing it requires engineering involvement. Even a small tweak, like adjusting when the review step shows up, means editing the component, updating validation, opening a pull request, waiting for review, and deploying again.<\/p>\n<h2 id=\"part-2-schema-driven-surveyjs\">Part 2: Schema-Driven (SurveyJS)<\/h2>\n<p>Now let\u2019s build the same flow using a schema.<\/p>\n<h3 id=\"installation-1\">Installation<\/h3>\n<pre><code class=\"language-bash\">npm install survey-core survey-react-ui @tanstack\/react-query\n<\/code><\/pre>\n<ul>\n<li><code>survey-core<\/code><br \/>\nThe MIT-licensed platform-independent runtime engine that powers SurveyJS\u2019s form rendering — the part we care about here. It takes a JSON schema, builds an internal model from it, and handles everything that would otherwise live in your React component: evaluating visibility expressions, computing derived values, managing page state, tracking validation, and deciding what \u201ccomplete\u201d means given which pages were actually shown.<\/li>\n<li><code>survey-react-ui<\/code><br \/>\nThe UI \/ rendering layer that connects that model to React. It\u2019s essentially a <code><Survey model={model} \/><\/code> component that re-renders whenever the engine\u2019s state changes. SurveyJS UI libraries are also available for <a href=\"https:\/\/www.npmjs.com\/package\/survey-angular\">Angular<\/a>, <a href=\"https:\/\/www.npmjs.com\/package\/survey-vue3-ui\">Vue3<\/a>, and many other frameworks.<\/li>\n<\/ul>\n<p>Together, they give you a fully functional, multi-page form runtime without writing a single line of control flow.<\/p>\n<p>The schema format itself is, as said before, just a JSON — no DSL or anything proprietary. You can inline it, import it from a file, fetch it from an API, or store it in a database column and hydrate it at runtime.<\/p>\n<h3 id=\"the-same-form-as-data\">The Same Form, As Data<\/h3>\n<p>Here\u2019s the same form, this time expressed as a JSON object. The schema defines everything: structure, validation, visibility rules, derived calculations, page navigation — and hands it to a <code>Model<\/code> that evaluates it at runtime. Here\u2019s what that looks like in full:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-javascript\">export const surveySchema = { \n title: \"Order Flow\", \n showProgressBar: \"top\", \n pages: [ \n { \n name: \"details\", \n elements: [ \n { type: \"text\", name: \"firstName\", isRequired: true }, \n { type: \"text\", name: \"email\", inputType: \"email\", isRequired: true, validators: [{ type: \"email\", text: \"Invalid email\" }] } \n ] \n }, \n { \n name: \"order\", \n elements: [ \n { type: \"text\", name: \"price\", inputType: \"number\", defaultValue: 0 }, \n { type: \"text\", name: \"quantity\", inputType: \"number\", defaultValue: 1 }, \n { \n type: \"dropdown\", \n name: \"taxRate\", \n defaultValue: 0.1, \n choices: [ \n { value: 0.05, text: \"5%\" }, \n { value: 0.1, text: \"10%\" }, \n { value: 0.15, text: \"15%\" } \n ] \n }, \n { \n type: \"expression\", \n name: \"subtotal\", \n expression: \"{price} * {quantity}\" \n }, \n { \n type: \"expression\", \n name: \"tax\", \n expression: \"{subtotal} * {taxRate}\" \n }, \n { \n type: \"expression\", \n name: \"total\", \n expression: \"{subtotal} + {tax}\" \n } \n ] \n }, \n { \n name: \"account\", \n elements: [ \n { \n type: \"radiogroup\", \n name: \"hasAccount\", \n choices: [\"Yes\", \"No\"] \n }, \n { \n type: \"text\", \n name: \"username\", \n visibleIf: \"{hasAccount} = 'Yes'\", \n isRequired: true \n }, \n { \n type: \"text\", \n name: \"password\", \n inputType: \"password\", \n visibleIf: \"{hasAccount} = 'Yes'\", \n isRequired: true, \n validators: [{ type: \"text\", minLength: 6, text: \"Min 6 characters\" }] \n }, \n { \n type: \"rating\", \n name: \"satisfaction\", \n rateMin: 1, \n rateMax: 5 \n }, \n { \n type: \"comment\", \n name: \"positiveFeedback\", \n visibleIf: \"{satisfaction} >= 4\" \n }, \n { \n type: \"comment\", \n name: \"improvementFeedback\", \n visibleIf: \"{satisfaction} <= 2\" \n } \n ] \n }, \n { \n name: \"review\", \n visibleIf: \"{total} >= 100\", \n elements: [] \n } \n ] \n};\n<\/code><\/pre>\n<\/div>\n<p>Compare this to the RHF version for a moment.<\/p>\n<ul>\n<li>The <code>superRefine<\/code> block that conditionally required <code>username<\/code> and <code>password<\/code> is gone. <code>visibleIf: "{hasAccount} = 'Yes'"<\/code> combined with <code>isRequired: true<\/code> handles both concerns together, on the field itself, where you’d expect to find them.<\/li>\n<li>The <code>useWatch<\/code> + <code>useMemo<\/code> chain that computed <code>subtotal<\/code>, <code>tax<\/code>, and <code>total<\/code> is replaced by three <code>expression<\/code> fields that reference each other by name.<\/li>\n<li>The review page condition, which in the RHF version was reconstructable only by tracing through <code>showSubmit<\/code>, the step 3 render branch.<\/li>\n<li>And finally, the nav button logic is a single <code>visibleIf<\/code> property on the page object.<\/li>\n<\/ul>\n<p>The same logic is there. It\u2019s just that the schema gives it a place to live where it\u2019s visible in isolation, rather than spread across the component.<\/p>\n<p>Also, note that the schema uses <code>type: 'expression'<\/code> for subtotal, tax, and total. <a href=\"https:\/\/surveyjs.io\/form-library\/documentation\/api-reference\/expression-model\">Expression<\/a> is read-only and used mainly to display calculated values. SurveyJS also supports <code>type: 'html'<\/code> for static content, but for calculated values, <code>expression<\/code> is the right choice.<\/p>\n<p>Now for the React side.<\/p>\n<h3 id=\"rendering-and-submission\">Rendering And Submission<\/h3>\n<p>Very simple. Wire <code>onComplete<\/code> to your API the same way — via <code>useMutation<\/code> or plain <code>fetch<\/code>:<\/p>\n<div class=\"break-out\">\n<pre><code class=\"language-javascript\">import { useState, useEffect, useRef } from \"react\"; \nimport { useMutation } from \"@tanstack\/react-query\"; \nimport { Model } from \"survey-core\"; \nimport { Survey } from \"survey-react-ui\"; \nimport \"survey-core\/survey-core.css\";\n\nexport function SurveyForm() { \n const [model] = useState(() => new Model(surveySchema));\n\n const mutation = useMutation({\n mutationFn: async (data) => {\n const res = await fetch(\"\/api\/orders\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application\/json\" },\n body: JSON.stringify(data),\n });\n if (!res.ok) throw new Error(\"Failed to submit\");\n return res.json();\n },\n });\n\n const mutationRef = useRef(mutation);\n mutationRef.current = mutation;\n useEffect(() => { \n const handler = (sender) => mutationRef.current.mutate(sender.data); \n model.onComplete.add(handler); \n return () => model.onComplete.remove(handler); \n }, [model]); \/\/ ref avoids re-registering handler every render (mutation object identity changes)\n\n return (\n <>\n <Survey model={model} \/> \n {mutation.isError && <div>Error: {mutation.error.message}<\/div>}\n <\/>\n );\n}\n<\/code><\/pre>\n<\/div>\n<figure class=\"break-out\">\n<p data-height=\"480\" data-theme-id=\"light\" data-slug-hash=\"emddWNV\" data-user=\"smashingmag\" data-default-tab=\"result\" class=\"codepen\">See the Pen [SurveyJS-03-SurveyJS [forked]](https:\/\/codepen.io\/smashingmag\/pen\/emddWNV) by <a href=\"https:\/\/codepen.io\/sixthextinction\">sixthextinction<\/a>.<\/p><figcaption>See the Pen <a href=\"https:\/\/codepen.io\/smashingmag\/pen\/emddWNV\">SurveyJS-03-SurveyJS [forked]<\/a> by <a href=\"https:\/\/codepen.io\/sixthextinction\">sixthextinction<\/a>.<\/figcaption><\/figure>\n<ul>\n<li><code>onComplete<\/code> fires when the user reaches the end of the last <em>visible<\/em> page. So if <code>total<\/code> never crosses 100 and the review page is skipped, it still fires correctly because SurveyJS evaluates visibility before deciding what \u201clast page\u201d means.<\/li>\n<li>Then, <code>sender.data<\/code> contains all answers along with the calculated values (<code>subtotal<\/code>, <code>tax<\/code>, <code>total<\/code>) as first-class fields, so the API payload is identical to what the RHF version assembled manually in <code>onSubmit<\/code>.<\/li>\n<li>The <code>mutationRef<\/code> pattern is the same one you\u2019d reach for anywhere you need a stable event handler over a value that changes on every render — nothing SurveyJS-specific about it.<\/li>\n<\/ul>\n<p>The React component no longer contains any business logic at all. There\u2019s no <code>useWatch<\/code>, no conditional JSX, no step counter, no <code>useMemo<\/code> chain, no <code>superRefine<\/code>. React is doing what it\u2019s actually good at: rendering a component and wiring it to an API call.<\/p>\n<h2 id=\"what-moved-out-of-react\">What Moved Out Of React?<\/h2>\n<table class=\"tablesaw break-out\">\n<thead>\n<tr>\n<th>Concern<\/th>\n<th>RHF Stack<\/th>\n<th>SurveyJS<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Visibility<\/td>\n<td>JSX branches<\/td>\n<td><code>visibleIf<\/code><\/td>\n<\/tr>\n<tr>\n<td>Derived values<\/td>\n<td><code>useWatch<\/code> \/ <code>useMemo<\/code><\/td>\n<td><code>expression<\/code><\/td>\n<\/tr>\n<tr>\n<td>Cross-field rules<\/td>\n<td><code>superRefine<\/code><\/td>\n<td>Schema conditions<\/td>\n<\/tr>\n<tr>\n<td>Navigation<\/td>\n<td><code>step<\/code> state<\/td>\n<td>Page <code>visibleIf<\/code><\/td>\n<\/tr>\n<tr>\n<td>Rule location<\/td>\n<td>Distributed across files<\/td>\n<td>Centralized in the schema<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>What stays in React is layout, styling, submission wiring, and app integration, which is to say, <strong>the things React is actually designed for<\/strong>.<\/p>\n<p>Everything else moved into the schema, and because the schema is just a JSON object, it can be stored in a database, versioned independently of your application code, or edited through internal tooling without requiring a deploy.<\/p>\n<p>A product manager who needs to change the threshold that triggers the review page can do that without touching the component. That\u2019s a meaningful operational difference for teams where form behavior evolves frequently and isn\u2019t always driven by engineers.<\/p>\n<h2 id=\"when-to-use-each-approach\">When To Use Each Approach?<\/h2>\n<p>Here\u2019s a good rule of thumb that works for me: <strong>imagine deleting the form entirely<\/strong>. What would you lose?<\/p>\n<ul>\n<li>If it\u2019s screens, you want component-driven forms.<\/li>\n<li>If it\u2019s business logic, like thresholds, branching rules, and conditional requirements that encode real decisions, you want a schema engine.<\/li>\n<\/ul>\n<p>Similarly, if the changes coming your way are mostly about labels, fields, and layout, RHF will serve you fine. If they\u2019re about conditions, outcomes, and rules that your ops or legal team might need to adjust on a Tuesday afternoon without filing a ticket, the schema model with SurveyJS is the more honest fit.<\/p>\n<p><strong>These two approaches are not really in competition with each other.<\/strong> They address different classes of problems, and the mistake worth avoiding is mismatching the abstraction to the weight of the logic — treating a rule system like a component because that\u2019s the familiar tool, or reaching for a policy engine because a form grew to three steps and acquired a conditional field.<\/p>\n<p>The form we built here sits near the boundary deliberately, complex enough to expose the difference but not so extreme that the comparison feels rigged. Most real forms that have gotten unwieldy in your codebase probably sit near that same boundary, and the question is usually just whether anyone has named what they actually are.<\/p>\n<p><strong>Use React Hook Form + Zod when:<\/strong><\/p>\n<ul>\n<li>Forms are CRUD-oriented;<\/li>\n<li>Logic is shallow and UI-driven;<\/li>\n<li>Engineers own all behavior;<\/li>\n<li>Backend remains the source of truth.<\/li>\n<\/ul>\n<p><strong>Use SurveyJS when:<\/strong><\/p>\n<ul>\n<li>Forms encode business decisions;<\/li>\n<li>Rules evolve independently of UI;<\/li>\n<li>Logic must be visible, auditable, or versioned;<\/li>\n<li>Non-engineers influence behavior;<\/li>\n<li>The same form must run across multiple frontends.<\/li>\n<\/ul>\n<div class=\"signature\">\n <img src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Smashing Editorial\" width=\"35\" height=\"46\" loading=\"lazy\" class=\"lazyload\" data-src=\"https:\/\/www.smashingmagazine.com\/images\/logo\/logo--red.png\"><br \/>\n <span>(yk)<\/span>\n<\/div>\n<\/article>\n","protected":false},"excerpt":{"rendered":"<p>Building Dynamic Forms In React And Next.js Building Dynamic Forms In React And Next.js Sunil Sandhu 2026-03-10T13:00:00+00:00 2026-03-18T09:33:12+00:00 This article is sponsored by SurveyJS There\u2019s 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…<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":[],"categories":[14],"tags":[],"_links":{"self":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts\/1170"}],"collection":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/comments?post=1170"}],"version-history":[{"count":1,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts\/1170\/revisions"}],"predecessor-version":[{"id":1171,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts\/1170\/revisions\/1171"}],"wp:attachment":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/media?parent=1170"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/categories?post=1170"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/tags?post=1170"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}