Most of us build Dynamic Forms in React on one assumption: a form is a component. In practice that means React Hook Form (RHF) holding the state and Zod validating it. That stack is the default for good reasons. It is fast, it is type safe, and it keeps each form to itself.
It starts to come apart the moment the form picks up visibility rules, fields that depend on other fields, and steps that branch. By then you are not building a UI, you are building a decision engine, and a JSX tree is a poor place to keep business rules.
Where RHF and Zod start to hurt
For a simple CRUD modal, RHF and Zod are hard to beat. As the form grows you start reaching for useWatch to follow live values and superRefine to push cross-field rules into Zod. A multi-step order flow usually ends up looking like this:
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" });
}
});
That superRefine block is where the decision logic starts leaking. Zod validates the shape of an object. It was never meant to hold the business rules that decide whether a field exists at all. Add inline JSX conditionals for visibility and your logic now lives in three places: the schema, the component state and the render branch.
I have watched that turn into validation firing before a field is even mounted, and stale state left behind after a user hits Back in a multi-step flow. Both are miserable to track down six months later.
The schema-driven alternative: SurveyJS
The other option is to treat the form as data, a JSON schema, instead of a component tree. A runtime engine such as SurveyJS works that way. Visibility and logic sit in a configuration object rather than in JSX branches.
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 knows nothing about the rules. It renders <Survey model={model} /> and waits for onComplete. Calculated values and skip logic are evaluated inside the engine, not in your component’s re-render cycle.
If reactive state handling is the part you care about, I looked at the same idea from another angle in the WordPress Interactivity API watch function.
Which model should you ship?
The choice is not about which library is better. It is about where the business logic belongs. One test: if you deleted the form tomorrow, would you be losing UI components or a set of rules?
- Use RHF + Zod when: the form is a flat CRUD screen, the UI is bespoke, and engineers own all of its behavior.
- Use SurveyJS when: the form encodes business decisions, the rules change on their own schedule, or people outside the engineering team need to read the logic.
If your form logic is eating your dev hours, I can take it on. I have been working with WordPress and high-scale frontends since the 4.x days.
Where the complexity goes
Complexity has to live somewhere. Force multi-step branching into a component-driven architecture and you are writing your own rules engine out of hooks and state, whether you meant to or not. Often the better call is to admit the form is really a set of rules and let a schema engine hold them.