How can I build a large dynamic form in React + TypeScript + MUI using a reusable components
02:16 19 Feb 2026

I’m building a large form in React + TypeScript using Material UI (MUI). The form has a lot of fields (100+), grouped into sections, and each field can be a different type: text, date, datetime, dropdown/select, checkbox, etc.


✅ Blog Post: Building Huge Forms in React with Reusable Blocks (Config-Driven + MUI)

Large forms are painful when every field is handwritten: repetitive , repetitive validation logic, and lots of boilerplate change handlers.

A scalable approach is to build config-driven forms:

  • You describe each field in config: name, label, type, required, options, validations, etc.
  • A generic InputMapper renders the right UI component.
  • A shared validation engine validates on change and/or on submit.
  • The form becomes easy to extend: add a new field config entry instead of writing new JSX.

This post shows a production-friendly structure with: ✅ text, textarea, number
date, datetime (MUI X Pickers + Dayjs)
select (dropdown), multiselect
checkbox
✅ Validation: required, pattern, length, numeric range, date min/max, and custom validate
✅ Sections for huge forms
✅ A reusable FormRenderer component


1) Install dependencies

npm i @mui/material @emotion/react @emotion/styled
npm i @mui/x-date-pickers dayjs

If you already use MUI, you likely already have Material + Emotion installed.


2) Suggested folder structure

src/
  form/
    types.ts
    validation.ts
    useFormState.ts
    FormRenderer.tsx
    InputMapper.tsx
    fields/
      TextInput.tsx
      DateInput.tsx
      SelectInput.tsx
      CheckboxInput.tsx
  pages/
    HugeFormExamplePage.tsx

✅ Code

src/form/types.ts

This defines strongly-typed field configs.

import { Dayjs } from "dayjs";

export type FieldType =
  | "text"
  | "textarea"
  | "number"
  | "date"
  | "datetime"
  | "select"
  | "multiselect"
  | "checkbox";

export type Option = { label: string; value: string };

export type CustomValidator = any> = (
  value: any,
  values: TValues,
  field: FieldConfig
) => string | undefined;

export interface ValidationRules = any> {
  required?: boolean;
  requiredMessage?: string;

  pattern?: RegExp;
  patternMessage?: string;

  minLength?: number;
  maxLength?: number;

  min?: number;
  max?: number;

  // For date/datetime types
  minDate?: Dayjs;
  maxDate?: Dayjs;

  validate?: CustomValidator;
}

interface BaseField {
  name: string;
  label: string;
  type: FieldType;
  helperText?: string;
  disabled?: boolean;
  defaultValue?: any;
  validation?: ValidationRules;
}

export interface TextFieldConfig extends BaseField {
  type: "text" | "textarea" | "number";
  placeholder?: string;
}

export interface DateFieldConfig extends BaseField {
  type: "date" | "datetime";
}

export interface SelectFieldConfig extends BaseField {
  type: "select" | "multiselect";
  options: Option[];
}

export interface CheckboxFieldConfig extends BaseField {
  type: "checkbox";
}

export type FieldConfig =
  | TextFieldConfig
  | DateFieldConfig
  | SelectFieldConfig
  | CheckboxFieldConfig;

export interface SectionConfig {
  title: string;
  description?: string;
  fields: FieldConfig[];
}

src/form/validation.ts

A single place to validate fields consistently.

import dayjs from "dayjs";
import { FieldConfig } from "./types";

export function validateField>(
  value: any,
  field: FieldConfig,
  values: TValues
): string {
  const rules = field.validation;

  // 1) Required
  const required = rules?.required ?? false;

  if (required) {
    const isEmpty =
      value === null ||
      value === undefined ||
      value === "" ||
      (Array.isArray(value) && value.length === 0) ||
      (field.type === "checkbox" && value !== true);

    if (isEmpty) return rules?.requiredMessage ?? `${field.label} is required`;
  }

  // 2) Pattern (usually for text)
  if (rules?.pattern && typeof value === "string" && value) {
    if (!rules.pattern.test(value)) {
      return rules.patternMessage ?? `${field.label} format is invalid`;
    }
  }

  // 3) Length (string)
  if (typeof value === "string" && value) {
    if (rules?.minLength && value.length < rules.minLength) {
      return `${field.label} must be at least ${rules.minLength} characters`;
    }
    if (rules?.maxLength && value.length > rules.maxLength) {
      return `${field.label} must be at most ${rules.maxLength} characters`;
    }
  }

  // 4) Numeric range
  if (field.type === "number" && value !== null && value !== undefined && value !== "") {
    const num = Number(value);
    if (Number.isNaN(num)) return `${field.label} must be a number`;
    if (rules?.min !== undefined && num < rules.min) {
      return `${field.label} must be >= ${rules.min}`;
    }
    if (rules?.max !== undefined && num > rules.max) {
      return `${field.label} must be <= ${rules.max}`;
    }
  }

  // 5) Date range
  if ((field.type === "date" || field.type === "datetime") && value) {
    const d = dayjs(value);
    if (!d.isValid()) return `${field.label} is invalid`;

    if (rules?.minDate && d.isBefore(rules.minDate)) {
      return `${field.label} must be after ${rules.minDate.format("YYYY-MM-DD")}`;
    }
    if (rules?.maxDate && d.isAfter(rules.maxDate)) {
      return `${field.label} must be before ${rules.maxDate.format("YYYY-MM-DD")}`;
    }
  }

  // 6) Custom validator
  if (rules?.validate) {
    const msg = rules.validate(value, values, field);
    if (msg) return msg;
  }

  return "";
}

export function validateAll>(
  fields: FieldConfig[],
  values: TValues
): Record {
  const errors: Record = {};
  for (const f of fields) {
    const err = validateField(values[f.name], f, values);
    if (err) errors[f.name] = err;
  }
  return errors;
}

src/form/useFormState.ts

A reusable hook that:

  • builds initial values from config
  • updates values
  • validates on change
  • validates all on submit
import { useMemo, useState } from "react";
import dayjs from "dayjs";
import { FieldConfig, SectionConfig } from "./types";
import { validateAll, validateField } from "./validation";

function flattenFields(sections: SectionConfig[]): FieldConfig[] {
  return sections.flatMap((s) => s.fields);
}

function buildInitialValues(fields: FieldConfig[]) {
  const values: Record = {};
  for (const f of fields) {
    if (f.defaultValue !== undefined) {
      values[f.name] = f.defaultValue;
      continue;
    }

    // sensible defaults by type
    switch (f.type) {
      case "text":
      case "textarea":
      case "number":
      case "select":
        values[f.name] = "";
        break;
      case "multiselect":
        values[f.name] = [];
        break;
      case "checkbox":
        values[f.name] = false;
        break;
      case "date":
      case "datetime":
        values[f.name] = null; // or dayjs() if you want current date
        break;
      default:
        values[f.name] = "";
    }
  }
  return values;
}

export function useFormState(sections: SectionConfig[]) {
  const fields = useMemo(() => flattenFields(sections), [sections]);

  const [values, setValues] = useState>(() =>
    buildInitialValues(fields)
  );
  const [errors, setErrors] = useState>({});

  const setFieldValue = (name: string, value: any) => {
    setValues((prev) => {
      const next = { ...prev, [name]: value };

      const field = fields.find((f) => f.name === name);
      if (field) {
        const err = validateField(value, field, next);
        setErrors((prevErr) => ({ ...prevErr, [name]: err }));
      }
      return next;
    });
  };

  const validateForm = () => {
    const nextErrors = validateAll(fields, values);
    setErrors(nextErrors);
    return nextErrors;
  };

  const reset = () => {
    setValues(buildInitialValues(fields));
    setErrors({});
  };

  return { fields, values, errors, setFieldValue, validateForm, reset, setValues, setErrors };
}

Note: dayjs import is included in case you want defaults like dayjs() for certain date fields later.


src/form/fields/TextInput.tsx

import React from "react";
import { TextField } from "@mui/material";
import { TextFieldConfig } from "../types";

type Props = {
  field: TextFieldConfig;
  value: any;
  error?: string;
  onChange: (name: string, value: any) => void;
};

export default function TextInput({ field, value, error, onChange }: Props) {
  const isTextArea = field.type === "textarea";

  return (
     onChange(field.name, e.target.value)}
      fullWidth
      disabled={field.disabled}
      placeholder={field.placeholder}
      multiline={isTextArea}
      minRows={isTextArea ? 3 : undefined}
      type={field.type === "number" ? "number" : "text"}
      error={!!error}
      helperText={error || field.helperText}
    />
  );
}

src/form/fields/DateInput.tsx

This uses MUI X DatePicker / DateTimePicker and forwards error/helperText cleanly.

import React from "react";
import { DatePicker, DateTimePicker } from "@mui/x-date-pickers";
import { Dayjs } from "dayjs";
import { DateFieldConfig } from "../types";

type Props = {
  field: DateFieldConfig;
  value: Dayjs | null;
  error?: string;
  onChange: (name: string, value: Dayjs | null) => void;
};

export default function DateInput({ field, value, error, onChange }: Props) {
  const Picker = field.type === "datetime" ? DateTimePicker : DatePicker;

  return (
     onChange(field.name, d)}
      disabled={field.disabled}
      slotProps={{
        textField: {
          fullWidth: true,
          name: field.name,
          error: !!error,
          helperText: error || field.helperText,
        },
      }}
    />
  );
}

✅ This slotProps approach is the most stable with modern MUI X.


src/form/fields/SelectInput.tsx

Supports both single select and multiselect.

import React from "react";
import {
  Checkbox,
  ListItemText,
  MenuItem,
  TextField,
} from "@mui/material";
import { SelectFieldConfig } from "../types";

type Props = {
  field: SelectFieldConfig;
  value: any;
  error?: string;
  onChange: (name: string, value: any) => void;
};

export default function SelectInput({ field, value, error, onChange }: Props) {
  const multiple = field.type === "multiselect";

  return (
     onChange(field.name, e.target.value)}
      disabled={field.disabled}
      error={!!error}
      helperText={error || field.helperText}
      SelectProps={{
        multiple,
        renderValue: multiple
          ? (selected) =>
              (selected as string[])
                .map((v) => field.options.find((o) => o.value === v)?.label ?? v)
                .join(", ")
          : undefined,
      }}
    >
      {field.options.map((opt) => (
        
          {multiple ? (
            <>
              
              
            
          ) : (
            opt.label
          )}
        
      ))}
    
  );
}

src/form/fields/CheckboxInput.tsx

import React from "react";
import { Checkbox, FormControlLabel, FormHelperText } from "@mui/material";
import { CheckboxFieldConfig } from "../types";

type Props = {
  field: CheckboxFieldConfig;
  value: boolean;
  error?: string;
  onChange: (name: string, value: boolean) => void;
};

export default function CheckboxInput({ field, value, error, onChange }: Props) {
  return (
    
onChange(field.name, e.target.checked)} disabled={field.disabled} /> } label={field.label} /> {error || field.helperText}
); }

src/form/InputMapper.tsx

The heart of reuse: choose which component to render based on field type.

import React from "react";
import { FieldConfig } from "./types";
import TextInput from "./fields/TextInput";
import DateInput from "./fields/DateInput";
import SelectInput from "./fields/SelectInput";
import CheckboxInput from "./fields/CheckboxInput";
import { Dayjs } from "dayjs";

type Props = {
  field: FieldConfig;
  value: any;
  error?: string;
  onChange: (name: string, value: any) => void;
};

export default function InputMapper({ field, value, error, onChange }: Props) {
  switch (field.type) {
    case "text":
    case "textarea":
    case "number":
      return (
        
      );

    case "date":
    case "datetime":
      return (
        
      );

    case "select":
    case "multiselect":
      return (
        
      );

    case "checkbox":
      return (
        
      );

    default:
      return (
        
      );
  }
}

✅ Notice we explicitly support "datetime" (your earlier code was missing that switch case).


src/form/FormRenderer.tsx

This renders the full form by sections and fields.

import React from "react";
import { Box, Button, Divider, Stack, Typography } from "@mui/material";
import InputMapper from "./InputMapper";
import { SectionConfig } from "./types";
import { useFormState } from "./useFormState";

type Props = {
  sections: SectionConfig[];
  onSubmit: (values: Record) => void;
};

export default function FormRenderer({ sections, onSubmit }: Props) {
  const { values, errors, setFieldValue, validateForm, reset } =
    useFormState(sections);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const nextErrors = validateForm();
    const hasErrors = Object.values(nextErrors).some(Boolean);
    if (!hasErrors) onSubmit(values);
  };

  return (
    
      
        {sections.map((section) => (
          
            {section.title}
            {section.description ? (
              
                {section.description}
              
            ) : null}

            

            
              {section.fields.map((field) => (
                
              ))}
            
          
        ))}

        
          
          
        
      
    
  );
}

src/pages/HugeFormExamplePage.tsx

This example shows how to define form fields purely via config.

import React from "react";
import { Container, Paper, Typography } from "@mui/material";
import FormRenderer from "../form/FormRenderer";
import { SectionConfig } from "../form/types";
import dayjs from "dayjs";

// IMPORTANT: MUI X Date Pickers require LocalizationProvider
import { LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";

const sections: SectionConfig[] = [
  {
    title: "Personal Info",
    description: "Basic info collected for profile creation.",
    fields: [
      {
        name: "fullName",
        label: "Full Name",
        type: "text",
        validation: { required: true, minLength: 2 },
      },
      {
        name: "bio",
        label: "Bio",
        type: "textarea",
        helperText: "Write a short description (optional)",
        validation: { maxLength: 240 },
      },
      {
        name: "age",
        label: "Age",
        type: "number",
        validation: { min: 1, max: 120 },
      },
    ],
  },
  {
    title: "Dates",
    fields: [
      {
        name: "startDate",
        label: "Start Date",
        type: "date",
        validation: {
          required: true,
          minDate: dayjs().subtract(1, "year"),
          maxDate: dayjs().add(1, "year"),
        },
      },
      {
        name: "appointmentAt",
        label: "Appointment (Date & Time)",
        type: "datetime",
        validation: { required: true },
      },
    ],
  },
  {
    title: "Preferences",
    fields: [
      {
        name: "role",
        label: "Role",
        type: "select",
        options: [
          { label: "Developer", value: "dev" },
          { label: "QA Engineer", value: "qa" },
          { label: "Manager", value: "mgr" },
        ],
        validation: { required: true },
      },
      {
        name: "skills",
        label: "Skills",
        type: "multiselect",
        options: [
          { label: "React", value: "react" },
          { label: "TypeScript", value: "ts" },
          { label: "Azure", value: "azure" },
        ],
        validation: {
          validate: (value) =>
            Array.isArray(value) && value.length > 0
              ? undefined
              : "Please select at least one skill",
        },
      },
      {
        name: "termsAccepted",
        label: "I accept the terms & conditions",
        type: "checkbox",
        validation: { required: true, requiredMessage: "You must accept terms" },
      },
    ],
  },
];

export default function HugeFormExamplePage() {
  const handleSubmit = (values: Record) => {
    // Typically: API call here
    console.log("Form Submit:", values);
  };

  return (
    
      
        
          
            Config-driven Huge Form (React + MUI)
          

          
        
      
    
  );
}

✅ How this scales to “huge” forms

A) Add a new field? Just add config

No new JSX. No new local state. No repeated handlers.

Example: add a field with pattern validation:

{
  name: "assetId",
  label: "Asset ID",
  type: "text",
  validation: {
    required: true,
    pattern: /^[A-Z0-9]+$/,
    patternMessage: "Only uppercase letters and numbers allowed",
  },
}

B) Add a new field type? Create one component + add it to mapper

Example: Want a radio type?

  1. Create RadioInput.tsx
  2. Update FieldType union and FieldConfig
  3. Add a case "radio": return

C) Validation stays centralized

Instead of scattered validations everywhere, validateField() ensures consistency.

D) Section-based rendering keeps UI manageable

Huge forms remain readable:

  • “Technical Attributes”
  • “PI Attributes”
  • “Name Plate Attributes”
  • etc.

javascript reactjs typescript