
Zod Validation Example with Form Renderer
- Authors
- Name
- Stephen ♔ Ó Conchubhair
- Bluesky
- @stethewhitefox.bsky.social
TL;DR
Building dynamic forms with consistent validation is hard — so I created a schema-first solution that renders fields automatically from JSON and Zod schemas.
This lightweight, schema-driven form renderer is built using React, TypeScript(TS) and Zod. It dynamically renders form fields from a JSON schema, manages internal state with a custom hook, and validates user input with strong typing and real-time feedback. Check out the code in this github repo 👉 View the source code on GitHub
- 👋 Why I Built This
- 🌉 My Journey: Bridging Art and Code
- UX Design
- 🔧 Key Concepts I Explored
- 📦 Getting Started
- 💡 Assumption & Design Notes
- 🗂 Project Structure
- 📖 Storybook Integration (New Update)
- Future State Management: Leveraging React Context API for Complex Forms (New Update)
- 🏗️ Future Enhancements
- 🚀 Production-Level Improvements
- 🧠 Why it matters:
👋 Why I Built This
Forms are everywhere in web apps — but building them can be repetitive and hard to scale. I wanted to explore a declarative approach: instead of hardcoding each form field, I wanted to define a schema and let the UI render itself.
This led me to build a Dynamic Form Renderer where:
- The form structure comes from a JSON schema
- Field components are reusable and type-safe
- Validation is centralized and reliable via Zod
It’s a great fit for admin panels, survey builders, or any app where forms change often.
🌉 My Journey: Bridging Art and Code
"I come from a unique background that bridges both art and technology. With degrees in both IT and Art, I am currently volunteering as a Digital Art Teacher where I mentor young people in HTML5, CSS3, JavaScript, problem-solving, creativity with creative tools including VSCode and 3D printing. This foundation has shaped how I approach UI development - blending aesthetics with functionality. This unique perspective has proven especially valuable throughout my career in UI and Art"
UX Design
I started by sketching in my Kobo reader and GIMP what I wanted to build. This is an important step, as it provides ownership, a visual plan, and a shared language between art and programming – the perfect entry point for both worlds.
I chose lavender after attending a UX meetup where one of the presenters as part of his presentation said that specific color got the most form submissions.

🔧 Key Concepts I Explored
📚 Structuring the lib Directory for Clarity
To establish a well-structured and maintainable codebase for my dynamic form, I creating a lib/
directory, making the codebase more organized, maintainable, and testable.
Separation of concerns — Organizing logic into focused files simplified testing and set the stage for easier future collaboration or handoff.
In the lib
directory, the core structure for the dynamic form is defined.
Starting the coding part with FormSchema
object schema for the dynamic form.
export const uiSchema: FormSchema = {
title: 'User Registration',
fields: [
{ label: 'Name', type: 'text', name: 'name', required: true },
{ label: 'Age', type: 'number', name: 'age' },
{ label: 'Subscribe', type: 'checkbox', name: 'subscribe' },
{
label: 'Gender',
type: 'select',
name: 'gender',
options: ['Male', 'Female', 'Other'],
},
{ label: 'D.O.B', type: 'date', name: 'date' },
{ label: 'Write', type: 'text', name: 'words' },
],
}
Then the type definitions definitions.ts
- definitions.ts
- Strongly Typed Fields: Enforces type safety, catching errors during development in VSCode
- Foundation for Form Structure: Interfaces like
FormSchema
,BaseField
, andField
define the building blocks for rendering the form dynamically - Enhanced Clarity & Debuggability: Centralized definitions enhance readability and simplify debugging
- Improved Extensibility: The modular structure makes it easy to add new field types
- Seamless Zod Integration: The
name
property aligns with Zod schema keys, ensuring robust runtime validation
✅ Ensuring Data Integrity with Zod
With the form structure and field types defined in definitions.ts
, the next step was to ensure data integrity. This is handled in schema.ts
which defines two key components:
validationZodSchema
- the Zod validation schema, responsible for validating submitted datauiSchema
- the UI schema, which defines how the form fields are rendered
The name
property in each field acts as the crucial bridge between the form's UI and its validation rules in validationZodSchema
. Without this precise mapping, Zod wouldn't know which validation rule applies to which input value. I chose Zod for its runtime validation, concise syntax, and seamless TS support.
For example:
z.string().min(2, ...)
ensures thename
field has at least two charactersdate: z.coerce.date().optional()
automatically converts a string into a Date and makes it optional.
The FormSchema
object rigorously defines the form's metadata and structure. By centralizing properties like the form's title
and a detailed list of fields
(each specifying label
, type
, name
, and optional options
), this design establishes a critical single source of truth for the form's UI. This highly declarative approach was reinforced as a best practice in recent discussions, as it dramatically enhances maintainability, scalability, and developer experience. It allows for rapid iteration on form structures without touching rendering logic, embodies strong separation of concerns, and significantly reduces boilerplate – all hallmarks of robust and adaptable front-end systems.
Both schemas are passed into the FormRenderer.tsx
component like so:
<FormRenderer schema={schema} zodSchema={validationZodSchema} />
Bringing it all together.
import { FormSchema } from './definitions'
import { z } from 'zod'
export const validationZodSchema = z.object({
name: z.string().min(2, { message: 'Name must be at least 2 characters.' }),
age: z
.number()
.min(0, 'Age must be 0 or more')
.max(120, 'Age must be 120 or less')
.optional(),
subscribe: z.boolean().default(false),
gender: z.enum(['Male', 'Female', 'Other']).optional(),
date: z.coerce.date().optional(),
words: z
.string()
.min(5, { message: 'Please write at least 5 characters.' })
.optional(),
})
export const uiSchema: FormSchema = {
title: 'User Registration',
fields: [
{ label: 'Name', type: 'text', name: 'name', required: true },
{ label: 'Age', type: 'number', name: 'age' },
{ label: 'Subscribe', type: 'checkbox', name: 'subscribe' },
{
label: 'Gender',
type: 'select',
name: 'gender',
options: ['Male', 'Female', 'Other'],
},
{ label: 'D.O.B', type: 'date', name: 'date' },
{ label: 'Write', type: 'text', name: 'words' },
],
}
Features
- Renders forms dynamically from a JSON schema
- Internal form state management
- Validation for required fields
- Displays submitted form data as JSON
- Modular, reusable field components
- Clean, responsive field components
👉 View the source code on GitHub
Tech Stack
- Frontend: React, Vite , TypeScript, styled components
- Validation: Zod
- Testing: Vitest + React Testing Library
- Tooling: Bun (optional) for faster installs and dev server
📦 Getting Started
For this project I used node -v v23.9.0
Important Note for Developers:
Node.js Compatibility Note:
- This project was built with Node v23.9.0.
- If you're using Create React App (CRA), please note that Node.js v18 reached end-of-life in April 2025.
- While CRA projects may still function on Node 18, it's recommended to use Node v20 LTS or later for long-term compatibility and security.
- This project uses Vite, which is fully compatible with modern Node versions.
Clone the repo
git clone https://github.com/theWhiteFox/React-TS-Dynamic-Form
cd React-TS-Dynamic-Form
bun install
# or
npm install
bun dev
# or
npm run dev
Once everything is working correctly in your browser open http://localhost:5173/ to run locally 🤞 this is what you will see

💡 Assumption & Design Notes
- The form is schema-driven and assumes a trusted schema source
- Each field is rendered using a reusable component based on its type
- Validation is handled via a zodSchema passed into the renderer
- The internal form state is strongly typed and centrally managed
- Easily extendable to add more validations or custom field types
🗂 Project Structure
react-ts-dynamic-form/
├── public/
├── src/
│ ├── __tests__/ # (Optional) Unit tests
│ ├── components/
│ │ ├── FormRenderer.tsx # Main renderer component
│ │ ├── FormWrapper.ts # Styled container & layout
│ │ └── fields/ # Field components
│ │ ├── CheckboxField.tsx
│ │ ├── SelectField.tsx
│ │ ├── NumberField.tsx
│ │ ├── TextField.tsx
│ │ └── DateField.tsx # Bonus: easily extendable
│ ├── hooks/
│ │ └── useForm.ts # Custom hook for state/validation
│ ├── lib/
│ │ ├── definitions.ts # Schema & field types
│ │ ├── schema.ts # Sample schema definition
│ │ └── utils.ts # (Optional) helper functions
│ ├── App.tsx
│ ├── main.tsx
├── index.html
├── vite.config.ts
├── package.json
└── README.md
🧩 Components
The dynamic form is composed of modular field components, each responsible for rendering a specific input type and handling user interaction in a schema-driven way.
📂 fields
Renders a "Subscribe" checkbox field. Uses the generic FieldProps
<boolean>
to bind to a boolean Zod schema field.Displays validation errors and improves accessibility via the label and title attributes.
<input
id={field.name}
type="checkbox"
name={field.name}
checked={value === true}
onChange={(e) => onChange(field.name, e.target.checked)}
title={`Input for ${field.label}`}
/>
Lets users select their date of birth using day, month, and year dropdowns.
Includes a reset option. To do: Prevent selecting future dates, and improve year selection UX.
<DatePicker
id={field.name}
selected={selectedDate}
onChange={handleChange}
maxDate={new Date()} // prevents future dates
dateFormat="dd-MM-yyyy"
placeholderText={`Select ${field.label}`}
showMonthDropdown
showYearDropdown
dropdownMode="select"
/>
Renders a dropdown field (e.g., for gender selection). Includes options and schema validation.
To do: Add a "Prefer not to say" option.
<S.Select
id={field.name}
name={field.name}
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
title={`Input for ${field.label}`}
>
<option value="">-- Select --</option>
{field.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</S.Select>
Accepts numeric input (e.g., for age).
Basic validation is in place with min=0 and max=120. Note: The min attribute was removed from the input element to ensure Zod validation is the primary source of error reporting in tests.
<S.SmallInput id={field.name} type="number" name={field.name} value={value} onChange={(e) => onChange(field.name, Number(e.target.value))} aria-label={field.label} placeholder={`Enter ${field.name}`} title={`Input for ${field.label}`} />
Handles free-text input (e.g., for name fields). Required — must be filled before form submission.
<S.Input id={field.name} type="text" name={field.name} value={value} onChange={(e) => onChange(field.name, e.target.value)} placeholder={`Enter ${field.name}`} title={`Input for ${field.label}`} />
Central component that orchestrates the rendering of form fields based on the schema and handles overall form logic. Renders form fields dynamically based on the provided schema. Passes props like value, error, and onChange to each field. Includes form-wide validation through the useForm hook and outputs data in a server-friendly structure.
Provides consistent styling and layout for the form. Styled-components file that encapsulates reusable form styles: input spacing, error messages, labels, and layout consistency across all field components.
🔗 Hooks
Instead of relying on existing, feature-rich form libraries like React Hook Form or Formik, I made a deliberate architectural choice to build a custom useForm
hook. This decision was driven by several key factors:
- Deep Understanding & Control: Building the hook from scratch provided a valuable opportunity to deeply understand the intricacies of form state management, validation lifecycle, and input handling at a fundamental level. This hands-on approach ensures complete control over the form's behavior and performance.
- Tailored to Schema-Driven Design: The project's core premise is a schema-driven architecture. A custom hook allowed for a precise, lightweight integration with both the JSON UI schema and the Zod validation schema, without the overhead or opinionated patterns of a larger library that might not align perfectly with this specific design.
- Reduced Bundle Size & Specificity: For this focused dynamic form renderer, a custom hook offered a leaner solution, avoiding the potential bundle size increase and broader feature set of a general-purpose form library that were not strictly necessary.
- Demonstrating Core React Skills: This approach highlights an ability to leverage core React patterns (like custom hooks and state management) to solve specific problems efficiently and effectively.
useForm.ts is a custom hook to simplify state management and validation. It uses Zod schema to validate fields and simplifies handling input logic.
Uses TS generics for strong typing from schema definition to formData and validation results.
Abstracts repetitive form logic, making field components leaner and more maintainable.
Used in FormRenderer like so:
const {
formData,
handleChange,
validate,
resetForm: resetFormState,
} = useForm(zodSchema)
Hook Code Overview
import { useState } from 'react'
import { z } from 'zod'
function useForm<Schema extends z.ZodObject<z.ZodRawShape>>(
schema: Schema,
initialValues: z.infer<Schema> = {} as z.infer<Schema>
) {
type FormData = z.infer<Schema>
const [formData, setFormData] = useState<FormData>(initialValues)
const [errors, setErrors] = useState<z.ZodError<FormData> | null>(null)
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const { name, value, type } = e.target as HTMLInputElement
const updatedValue =
type === 'checkbox' ? (e.target as HTMLInputElement).checked : value
setFormData((prev: FormData) => ({
...prev,
[name]: updatedValue,
}))
}
const validate = (data: FormData) => {
const result = schema.safeParse(data)
if (!result.success) {
setErrors(result.error) // Directly set the ZodError object
} else {
setErrors(null)
}
return result
}
const resetForm = () => {
setFormData(initialValues)
setErrors(null)
}
return { formData, handleChange, validate, resetForm, errors }
}
export default useForm
🧪 tests
FormRenderer.test.tsx Unit test for the FormRenderer component. Covers rendering behavior schema-based validation, and interaction scenarios like input changes and submissions errors.
📖 Storybook Integration (New Update)
While this project is relatively small, I made a deliberate decision to integrate Storybook. My experience, particularly as reinforced in recent technical discussions, highlights its indispensable value in modern front-end development, even for projects of this scale, and especially for larger ones.
Storybook serves as a crucial tool for implementing Atomic Design principles. It allows me to build and test UI components in complete isolation—starting from the smallest building blocks (like individual CheckboxInputField.tsx
or TextField.tsx
components, akin to "atoms") and progressively combining them into more complex structures. This granular approach ensures:
- Consistency and Reusability: Each component is designed and tested independently, guaranteeing consistent behavior and appearance wherever it's used.
- Streamlined Development Workflow: Developers can focus on a single component without the overhead of the entire application, leading to faster iteration and debugging.
- Enhanced Collaboration: It provides a visual, interactive style guide that bridges the gap between design, development, and QA, allowing stakeholders to view and interact with components directly.
For larger projects or in a dedicated component library, Storybook's power truly shines. It becomes an invaluable central hub where you can:
- Document all UI components in a living, interactive environment.
- Visually regression test changes across your component ecosystem, catching unintended side effects early.
- Facilitate onboarding for new team members by providing a clear overview of available UI elements.
This integration significantly enhanced the UI development workflow for this project and demonstrates a forward-thinking approach to building scalable and maintainable user interfaces. You can explore the story for each component here.
Future State Management: Leveraging React Context API for Complex Forms (New Update)
While the current implementation uses a custom hook for form state management, building dynamic forms often presents a common challenge: prop drilling. As the form grows in complexity, passing data (like from values, change handlers, validation rules, and schema definitions) down through multiple component layers can lead to components can lead to cluttered code, reduced readability, decreased maintainability and become cumbersome and error-prone.
To proactively address such architectural complexities, I refactored the form state management using the React Context API. This allows me to:
- Centralize Form State: Manage form data, validation, and submission logic in a single context provider.
- Simplify Component Hierarchy: Eliminate the need to pass props through multiple layers, making components cleaner and more focused on rendering.
- Improve Reusability: Field components can independently access form state and actions from context directly, making them more modular and portable.
I chose React Context over Redux because the form state is relatively simple and doesn’t justify Redux’s boilerplate and added complexity. If the project grows in complexity, I’d consider using Zustand as a lightweight and more ergonomic alternative to Redux.
🏗️ Future Enhancements
- Add async username/email validation and mock an API delay. This has huge practical value, particularly when considering how to robustly handle event handler errors arising from asynchronous operations. In a production environment, ensuring graceful failure and clear user feedback is paramount. This would involve implementing
try...catch
blocks within event handlers (e.g.,onChange
oronBlur
for real-time validation) to catch network or server-side validation errors, and then updating the form's state to display appropriate error messages to the user. This demonstrates a comprehensive approach to building resilient forms. - Show a use case like a survey builder or admin dashboard in a real-world demo.
- Add "Prefer not to say" to dropdowns.
- Add form-level validation message display.
- Deploy the live version (e.g., Vercel/Netlify) and embed a video or screenshots.
- Compare design decisions — e.g., why Zod over Yup, why a custom hook over React Hook Form.
- Write a follow-up post: “Scaling Dynamic Forms in Production: Lessons Learned”.
🚀 Production-Level Improvements
- Implement internationalization (i18n) support for multi-language forms.
- Optimize performance for handling large forms with dynamic content.
- Set up CI/CD pipelines for automated testing and deployment.
- Enhance accessibility (e.g., keyboard navigation, screen reader support).
- Introduce advanced error handling, logging, and monitoring for better observability in production.
- Support for multi-step forms and progress indicators for user experience enhancement.
🧠 Why it matters:
Dynamic form rendering can greatly simplify UI development, especially in apps with constantly evolving requirements. This project gave me a practical way to explore type-safe form building, custom hooks, and schema validation. Here is a Live demo. Feedback and PRs are welcome!