We need to talk about how we build Dynamic Forms in React. There is a mental model most of us share: forms are components. Usually, that means a stack involving React Hook Form (RHF) for state management and Zod for validation. It’s the industry standard for a reason—it’s performant, type-safe, and isolated.
But here’s the problem. The moment your form starts accumulating complex visibility rules, cross-field dependencies, or branching navigation logic, that component-driven model starts to rot. You aren’t just building a UI anymore; you’re building a decision engine, and the JSX tree is a messy place to store business logic.
The Component-Driven Trap: RHF + Zod
For simple CRUD modals, RHF + Zod is unbeatable. But as things scale, you end up reaching for useWatch to track live values and superRefine to force-feed cross-field rules into Zod. Take a look at this typical setup for a multi-step order flow:
import { z } from "zod";
export const formSchema = z.object({
firstName: z.string().min(1, "Required"),
email: z.string().email("Invalid email"),
hasAccount: z.enum(["Yes", "No"]),
username: z.string().optional(),
satisfaction: z.number().min(1).max(5),
}).superRefine((data, ctx) => {
if (data.hasAccount === "Yes" && !data.username) {
ctx.addIssue({ code: "custom", path: ["username"], message: "Required" });
}
});
Notice the superRefine block? That’s where the “decision process” starts leaking. Zod was built to validate the *shape* of an object, not the *business rules* governing when a field exists. When you combine this with inline JSX conditionals for visibility, you’ve distributed your logic across three different places: the schema, the component state, and the render branch.
I’ve seen this lead to “Race Conditions” where validation triggers before a field is even mounted, or “Transients” where state isn’t cleared properly after a user goes “Back” in a multi-step flow. It’s a maintenance nightmare waiting to happen.
The Schema-Driven Alternative: SurveyJS
The alternative is to treat the form as data—a JSON schema—rather than a component tree. This is where a runtime engine like SurveyJS changes the game. Instead of writing JSX branches, you define visibility and logic inside a configuration object.
export const surveySchema = {
pages: [
{
name: "account",
elements: [
{ type: "radiogroup", name: "hasAccount", choices: ["Yes", "No"] },
{
type: "text",
name: "username",
visibleIf: "{hasAccount} = 'Yes'",
isRequired: true
}
]
}
]
};
In this model, the React component doesn’t care about the rules. It just renders a <Survey model={model} /> and waits for the onComplete hook. All the “Calculated Values” and “Skip Logic” are evaluated by the engine’s runtime, not your component’s re-render cycle.
If you’re interested in how modern APIs are handling state synchronization, you might find my look at the WordPress Interactivity API watch function relevant, as it shares some of these reactive principles.
Which Model Should You Ship?
Deciding between these two isn’t about which tool is “better.” It’s about where the business logic belongs. Ask yourself: if I deleted the form, would I lose UI components or a set of rules?
- Use RHF + Zod when: Your form is a flat CRUD screen, the UI is bespoke, and engineers own the entire behavior.
- Use SurveyJS when: Your form encodes complex business decisions, the rules evolve independently of the UI, or you need non-engineers to be able to audit the logic.
Look, if this Dynamic Forms in React stuff is eating up your dev hours, let me handle it. I’ve been wrestling with WordPress and high-scale frontends since the 4.x days.
The Bottom Line
Complexity has to live somewhere. If you force complex, multi-step branching into a component-driven architecture, you’re essentially building a custom engine from scratch using Hooks and state. Sometimes, the most senior move you can make is admitting that a dedicated schema engine is the more honest fit for the problem at hand. Stop over-engineering your components and start treating your logic as data.