Большие формы болезненно, когда каждое поле написано от руки: повторяющийся , повторяющаяся логика проверки и множество шаблонных обработчиков изменений.
Масштабируемый подход заключается в создании форм, управляемых конфигурацией:
- Вы описываете каждое поле в конфигурации: имя, метка, тип, требуемые, параметры, проверки, и т. д.
- Общий InputMapper отображает правильный компонент пользовательского интерфейса.
- Общий механизм проверки проверяет изменения и/или отправку.
- Форма становится легко расширяемой: добавьте новую запись конфигурации поля вместо написания нового JSX.
1) Установите зависимости
Код: Выделить всё
npm i @mui/material @emotion/react @emotion/styled
npm i @mui/x-date-pickers dayjs
2) Рекомендуемая структура папок
Код: Выделить всё
src/
form/
types.ts
validation.ts
useFormState.ts
FormRenderer.tsx
InputMapper.tsx
fields/
TextInput.tsx
DateInput.tsx
SelectInput.tsx
CheckboxInput.tsx
pages/
HugeFormExamplePage.tsx
Код: Выделить всё
src/form/types.tsКод: Выделить всё
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 = (
value: any,
values: TValues,
field: FieldConfig
) => string | undefined;
export interface ValidationRules {
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Код: Выделить всё
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 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 };
}
Код: Выделить всё
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Код: Выделить всё
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,
},
}}
/>
);
}
Код: Выделить всё
src/form/fields/SelectInput.tsxКод: Выделить всё
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 (
}
label={field.label}
/>
{error || field.helperText}
);
}
Код: Выделить всё
src/form/InputMapper.tsxКод: Выделить всё
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 (
);
}
}
Код: Выделить всё
src/form/FormRenderer.tsxКод: Выделить всё
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) => (
))}
))}
Reset
Submit
);
}
Код: Выделить всё
src/pages/HugeFormExamplePage.tsxКод: Выделить всё
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)
);
}
А) Добавить новое поле? Просто добавьте конфигурацию
Никакого нового JSX. Никакого нового местного штата. Никаких повторяющихся обработчиков.
Пример: добавьте поле с проверкой шаблона:
Код: Выделить всё
{
name: "assetId",
label: "Asset ID",
type: "text",
validation: {
required: true,
pattern: /^[A-Z0-9]+$/,
patternMessage: "Only uppercase letters and numbers allowed",
},
}
Пример: Хотите тип радио?
- Создайте RadioInput.tsx
- Обновите объединение FieldType и FieldConfig
- Добавьте регистр «радио»: return
Вместо разрозненных проверок повсюду, validateField() обеспечивает согласованность.
D) Отрисовка на основе разделов обеспечивает управляемость пользовательского интерфейса
Огромные формы остаются читабельными:
- «Технические атрибуты»
- »PI Атрибуты»
- «Атрибуты именной таблички»
- и т. д.
Подробнее здесь: https://stackoverflow.com/questions/798 ... -a-reusabl
Мобильная версия