Schema-driven, type-safe form builder for MUI + React Hook Form + Zod
Generate complex, production-ready forms from a plain JSON config. No boilerplate. No manual register calls. Full TypeScript inference from your Zod schema through to your onSubmit handler.
- Zero-config forms — one
fieldsarray, oneschema, done - Type-safe submit —
onSubmitdata is fully typed from your Zod schema - MUI-native — built on
@mui/materialv9, not bolted on - Async autocomplete — debounced fetch with built-in stale-response protection
- Conditional fields — hide/show fields based on other field values
- Performance — fields without
visibleIfnever re-render on sibling changes - Accessible — proper
<label htmlFor>,aria-required,aria-invalid,aria-describedby - Virtualization — optional
react-windowsupport for 50+ field forms
npm install mui-schema-form-builderPeer dependencies (install these if you don't have them):
npm install react react-dom @mui/material @emotion/react @emotion/styled \
react-hook-form @hookform/resolvers zodOptional (only needed when virtualize={true}):
npm install react-windowimport { z } from 'zod';
import { FormBuilder, FIELD_TYPE } from 'mui-schema-form-builder';
import { ThemeProvider, createTheme } from '@mui/material';
const schema = z.object({
name: z.string().min(2, 'Name is too short'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18+'),
});
const fields = [
{ name: 'name', label: 'Full Name', type: FIELD_TYPE.TEXT, required: true },
{ name: 'email', label: 'Email Address', type: FIELD_TYPE.TEXT, required: true },
{ name: 'age', label: 'Age', type: FIELD_TYPE.NUMBER, required: true },
];
export default function App() {
return (
<ThemeProvider theme={createTheme()}>
<FormBuilder
fields={fields}
schema={schema}
onSubmit={(data) => {
// data.name → string (TypeScript inferred from schema)
// data.email → string
// data.age → number
console.log(data);
}}
/>
</ThemeProvider>
);
}| Property | Type | Required | Description |
|---|---|---|---|
name |
string |
✓ | Field name — must match a key in your Zod schema |
label |
string |
✓ | Display label |
type |
FieldType |
✓ | See field types below |
defaultValue |
unknown |
Initial value | |
placeholder |
string |
Input placeholder | |
required |
boolean |
Shows asterisk, sets aria-required |
|
disabled |
boolean |
Disables the field | |
options |
Option[] |
For SELECT, RADIO, CHECKBOX | |
multiple |
boolean |
Multi-select for SELECT and AUTOCOMPLETE | |
grid |
GridConfig |
MUI Grid size — e.g. { xs: 12, sm: 6 } |
|
size |
'small' | 'medium' |
MUI component size | |
fullWidth |
boolean |
Full-width input (default true) |
|
min |
number |
Min value — NUMBER only; also sets HTML min |
|
max |
number |
Max value — NUMBER only; also sets HTML max |
|
step |
number |
Step — NUMBER only; also sets HTML step |
|
fetchOptions |
(query: string) => Promise<Option[]> |
Async options for AUTOCOMPLETE | |
visibleIf |
(values: FieldValues) => boolean |
Hides field when returns false |
|
muiProps |
Record<string, any> |
Extra props forwarded to the underlying MUI component |
import { FIELD_TYPE } from 'mui-schema-form-builder';
FIELD_TYPE.TEXT; // <input type="text">
FIELD_TYPE.TEXTAREA; // <textarea> (multiline)
FIELD_TYPE.NUMBER; // <input type="number">
FIELD_TYPE.DATE; // <input type="date">
FIELD_TYPE.SELECT; // <Select> single or multi
FIELD_TYPE.AUTOCOMPLETE; // <Autocomplete> static or async
FIELD_TYPE.RADIO; // <RadioGroup>
FIELD_TYPE.CHECKBOX; // Boolean or checkbox groupPass any Zod schema. The library uses @hookform/resolvers/zod internally:
const schema = z
.object({
password: z.string().min(8).regex(/[A-Z]/, 'Needs uppercase'),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: 'Passwords must match',
path: ['confirm'],
});Control when validation runs:
<FormBuilder
validationMode="onChange" // 'onChange' | 'onBlur' | 'onTouched' | 'onSubmit'
...
/>Only fields with visibleIf subscribe to form state changes. All other fields are isolated — typing in one field does not re-render its siblings.
const fields = [
{
name: 'status',
label: 'Status',
type: FIELD_TYPE.SELECT,
options: [
{ label: 'Employed', value: 'employed' },
{ label: 'Student', value: 'student' },
],
},
{
name: 'company',
label: 'Company',
type: FIELD_TYPE.TEXT,
visibleIf: (values) => values['status'] === 'employed',
},
];Built-in 300ms debounce and stale-response protection. If a later search resolves before an earlier one, the earlier response is discarded.
{
name: 'country',
label: 'Country',
type: FIELD_TYPE.AUTOCOMPLETE,
fetchOptions: async (query) => {
const res = await fetch(`/api/countries?q=${query}`);
const data = await res.json();
return data.map((c: Country) => ({ label: c.name, value: c.code }));
},
}| Prop | Type | Default | Description |
|---|---|---|---|
fields |
FieldConfig[] |
required | Field configuration |
schema |
z.ZodType |
required | Zod validation schema |
onSubmit |
(data: z.infer<TSchema>) => void | Promise<void> |
required | Typed submit handler |
onCancel |
() => void |
Renders Cancel button when provided | |
onReset |
() => void |
Renders Reset button when provided | |
submitText |
string |
'Submit' |
Submit button label |
cancelText |
string |
'Cancel' |
Cancel button label |
resetText |
string |
'Reset' |
Reset button label |
spacing |
number |
2 |
MUI Grid spacing between fields |
virtualize |
boolean |
false |
Enable react-window for large forms |
validationMode |
ValidationMode |
'onTouched' |
When validation triggers |
The generic propagates from schema → onSubmit automatically:
const schema = z.object({ name: z.string(), age: z.number() });
<FormBuilder
schema={schema}
fields={fields}
onSubmit={(data) => {
// data.name → string ✓
// data.age → number ✓
}}
/>;Memoize your fields array to prevent unnecessary recomputation of default values:
const fields = useMemo<FieldConfig[]>(
() => [{ name: 'name', label: 'Name', type: FIELD_TYPE.TEXT }],
[],
);- Every input has a proper
<label htmlFor>association — clicking the label focuses the input - Required asterisk is
aria-hidden(visual cue only) - Inputs have
aria-required,aria-invalid,aria-describedbylinked to error messages - Error messages have
role="alert"for screen reader announcement - Radio groups and checkbox groups use
<fieldset>+<legend>(WCAG 1.3.1)
MIT © Arjun Prakash