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
InputMapperrenders 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:
dayjsimport is included in case you want defaults likedayjs()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) => (
))}
);
}
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?
- Create
RadioInput.tsx - Update
FieldTypeunion andFieldConfig - 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.