theWhiteFoxDev
Schema-Driven Dynamic Forms in React with TypeScript & Zod

Schema-Driven Dynamic Forms in React with TypeScript & Zod

Authors

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

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

📦 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

local-host-dynamic-form
localhost:5173

💡 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>
    

    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}`}
        />
    
  • 📋 FormRenderer.tsx

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!