How would you design a dynamic form builder in React?
Advertisement
🧩 Scenario
🧠 Architecture Walkthrough
Schema-Driven Rendering as the Single Source of Truth
The most consequential decision in this design is making the JSON schema the single source of truth for everything about a field: its type, its label, its validation rules, and its visibility conditions. This sounds obvious until you consider the alternative a mix of hardcoded JSX, conditional renders scattered across the component tree, and ad hoc validation checks.
The schema approach forces all domain knowledge into a single data structure that can be fetched from an API, versioned, diffed, and swapped at runtime. In the demo, switching between userProfile and jobApplication schemas resets the entire form by changing a single string key a capability that would require significant refactoring in a hardcoded form.
The key={selectedSchema} prop on DynamicForm is a subtle but important detail: it causes React to unmount and remount the entire form component when the schema changes, guaranteeing that stale state from the previous schema doesn't bleed through.
Conditional Visibility Evaluated at the Form Level, Not the Field Level
The isFieldVisible function is a pure utility that takes a field definition and the entire form data object and returns a boolean. This function runs inside a useMemo in the parent DynamicForm component to produce visibleFields a filtered list recalculated any time formData or schema.fields changes.
The important architectural decision here is where visibility is evaluated: in the parent, not inside individual field components. If DynamicField components were responsible for deciding their own visibility, they would need access to the entire form state (coupling them tightly to a shared context) and would still be mounted and consuming memory even when hidden.
By filtering at the parent level, hidden fields are never rendered at all which also means their DOM nodes, event listeners, and any internal state are fully cleaned up. The two visibleWhen operators in the demo equals and in cover the majority of real conditional logic without introducing a general-purpose expression evaluator, which would be over-engineered for most applications.
Validation Scoped to Visible Fields Only
The validateForm function iterates over visibleFields rather than schema.fields. This is not an oversight it is a deliberate product decision. If a user selects "Frontend Developer" as their position, the backendTech and designTools fields are hidden.
Those fields may be defined as required in the schema for certain positions, but validating them when they are invisible would block the user from submitting a form they genuinely completed. The corollary is that when a visibleWhen condition resets (e.g., the user changes their position from frontend to designer), previously filled values for technologies remain in formData but are neither validated nor submitted in a meaningful way.
A production system would likely clear those values on visibility change, but the demo's approach of keeping stale data is simpler to reason about and avoids jarring resets if the user toggles back. The useCallback on handleFieldChange is also notable: it clears field-level errors as soon as the user touches the field, which prevents stale red borders from persisting after the user has already corrected their input.
💡 Key Code Explained
const visibleFields = useMemo(() => {
return schema.fields.filter((field) => isFieldVisible(field, formData));
}, [schema.fields, formData]);
This is the central reactive binding in the entire component. useMemo ensures the filtering computation runs only when formData or schema.fields actually changes not on every render caused by unrelated state updates like isSubmitting.
The result visibleFields drives both rendering (visibleFields.map(...)) and validation (visibleFields.forEach(...)), which means the form is always self-consistent: what the user sees is exactly what gets validated and submitted.
If this were recomputed inline without memoization, it would still be correct but would recalculate on every render tick including the render triggered by the isSubmitting state flip, which is unnecessary work.
const isFieldVisible = (field, formData) => {
if (!field.visibleWhen) return true;
const { field: dependentField, equals, operator, values } = field.visibleWhen;
const dependentValue = formData[dependentField];
if (operator === 'in') {
return values && values.includes(dependentValue);
}
return dependentValue === equals;
};
This function is deliberately kept as a pure function outside the component rather than a method or hook. Being pure makes it trivially testable in isolation you can pass in any field definition and any form data object and assert the result without mounting any component.
The two-operator design (equals and in) is intentional minimalism. A junior developer's instinct might be to support arbitrary JavaScript expressions in visibleWhen for maximum flexibility, but that approach introduces serious security concerns (eval-based) or significant complexity (building a condition expression parser).
The in operator handles the most common real-world case "show this field when the user picks one of several options" without either of those downsides.
⚖️ Tradeoffs
| Approach | Pro | Con |
|---|---|---|
JSON schema with built-in validateField | Self-contained, no external deps, easy to understand | Schema and validation logic drift apart as rules grow complex |
| JSON schema + Zod/Yup for validation | Validation mirrors backend schema, reusable across layers | Adds bundle weight; Zod schema must stay in sync with JSON field config |
| Hardcoded JSX per form | Simple, fully typed, easy to debug | Zero reusability; adding a new form means writing a new component from scratch |
react-hook-form with dynamic fields | Battle-tested, performant, accessible by default | useFieldArray API has a learning curve; schema metadata must be mapped to RHF register options |
🎯 What Interviewers Actually Check
- Whether you know to validate only visible fields failing to do so means invisible required fields block submission
- Whether you use
key={selectedSchema}or a manual reset mechanism when switching schemas most candidates forget to reset form state - Whether
isFieldVisibleis pure and lives outside the component placing it inside creates a new function reference per render - Whether you clear field errors on change rather than only on submit the demo does this in
handleFieldChange - Whether you understand why
visibleFieldsdrives both the render and the validation loop without this coupling, the form can show fields that aren't validated, or validate fields that aren't shown
❓ Follow-Up Questions
- How would you extend
visibleWhento support compound conditions, such as "show this field only when field A equals X AND field B is not empty"? - What happens if two fields have a circular
visibleWhendependency field A is visible when B has a value, and field B is visible when A has a value? - How would you write unit tests for
validateFieldandisFieldVisible, and what edge cases would you cover? - If the schema is fetched from an API and can change between form renders, how do you prevent stale field values from an old schema from being submitted with a new schema?
- Your product manager asks you to support schema-level "submit guards" rules that prevent submission unless a combination of fields meets certain conditions. How do you model this without breaking the per-field validation design?
🎮 Live Demo
📝 Summary
A dynamic form builder derives its power from treating the JSON schema as the authoritative description of the form not just the field list, but also the validation rules and conditional visibility logic.
The critical insight is that visibility evaluation must happen at the parent level and must feed directly into the validation pass, so that hidden fields can never block form submission. The key={selectedSchema} reset pattern is a small but easily overlooked detail that prevents state contamination when the schema changes.
In a production system, you would replace the hand-rolled validateField with Zod or Yup to get type-safe validation that mirrors your backend schema, but the architectural skeleton schema drives rendering, parent evaluates visibility, validation scopes to visible fields only scales cleanly regardless of the validation library underneath.
What is a JSON-driven form builder?
A system that takes a configuration object or schema and renders a form dynamically without hardcoding fields.
How can you handle conditional field visibility?
You can include a `visibleWhen` property in the schema, evaluate it based on current form state, and skip rendering when false.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement