
Schema-Driven Dynamic Forms in React with TypeScript & Zod
- 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.
A lightweight, schema-driven form renderer using React, TypeScript, 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
- 🔧 Key Concepts I Explored
- 📦 Getting Started
- 💡 Assumption & Design Notes
- 🗂 Project Structure
- 📖 Storybook Integration (New Update)
- "🚧 Future Enhancements"
- 🧠 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, 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.
🔧 Key Concepts I Explored
Schema-Driven Rendering
The form is rendered dynamically from this schema:
import { FormSchema } from './definitions'
import { z } from 'zod'
export const myFormSchema = z.object({
name: z.string().min(2, { message: 'Name must be at least 2 characters.' }),
age: z.coerce
.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 schema: 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
Tech Stack
- Vite + React
- TypeScript
- styled components
- Vitest + React Testing Library for unit tests
- Schema validator zod
- Bun (optional) for faster installs and dev server
📦 Getting Started
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.name}`} > <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.name}`} />
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.name}`} />
Central component that brings all fields together. 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.
Styled-components file that encapsulates reusable form styles: input spacing, error messages, labels, and layout consistency across all field components.
🔗 Hooks
useForm 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
📚 lib
- definitions.ts - Contains shared TS types and interfaces for field definitions and form metadata.
- schema.ts - Defines Zod schemas to validate from structure and enforce field rules.
- utils.ts - Includes utitlty functions used across the form system, such as value transformers or type guards.
🧪 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)
To improve the development and testing experience, I integrated Storybook into the project. Storybook allows me to view and interact with each form field component in isolation, ensuring they work as expected before integrating them into the full form renderer.
This integration has significantly enhanced the UI development workflow, especially when dealing with reusable components. You can explore the story for each component here.
"🚧 Future Enhancements"
- Add async username/email validation and mock an API delay — huge practical value.
- 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”.
🧠 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!