mui-schema-form-builder
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.
Features
- 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 - Password input —
FIELD_TYPE.PASSWORDwith a built-in show/hide toggle (no icon library needed) - Input adornments —
startAdornment/endAdornmenton TEXT and NUMBER fields for prefixes, suffixes, and icons - Form title — optional heading with alignment (
titleAlign) and placement (titlePosition) control - Custom action buttons — replace Submit/Cancel/Reset with your own layout via
renderActions - Multi-step wizard —
FormWizardwith per-step validation, completed-step navigation, and submit-error navigation - 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
Installation
npm install mui-schema-form-builder
Peer 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 zod
Optional (only needed when virtualize={true}):
npm install react-window
Quick Start
import { 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>
);
}
Field Schema Reference
| 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 |
|
rows |
number |
Visible text rows — TEXTAREA only (default 4) |
|
startAdornment |
React.ReactNode |
Prefix node inside the input (TEXT, NUMBER). E.g. "$", icon |
|
endAdornment |
React.ReactNode |
Suffix node inside the input (TEXT, NUMBER). E.g. "kg" |
|
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 | |
section |
string |
Groups consecutive same-section fields under a shared header |
Field Types
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.PASSWORD; // Password input with show/hide toggle
FIELD_TYPE.SELECT; // <Select> single or multi
FIELD_TYPE.AUTOCOMPLETE; // <Autocomplete> static or async
FIELD_TYPE.RADIO; // <RadioGroup>
FIELD_TYPE.CHECKBOX; // Boolean or checkbox group
FIELD_TYPE.ARRAY; // Dynamic list with add/remove (useFieldArray)
FIELD_TYPE.DATE_PICKER; // MUI DatePicker — register via createDatePickerInput
Password Input
Use FIELD_TYPE.PASSWORD for a text input with a built-in show/hide toggle. The toggle uses an inline SVG icon — no @mui/icons-material dependency needed.
{
name: 'password',
label: 'Password',
type: FIELD_TYPE.PASSWORD,
required: true,
}
Input Adornments
Add a prefix or suffix decoration to TEXT and NUMBER fields via startAdornment and endAdornment. Pass any React node — a string, an icon, or an interactive element.
[
{
name: 'price',
label: 'Price',
type: FIELD_TYPE.NUMBER,
startAdornment: '
Note: PASSWORD fields have their own fixed end adornment (the visibility toggle). Setting endAdornment on a PASSWORD field has no effect.
Form Title
Add a heading to FormBuilder or FormWizard with the title, titleAlign, and titlePosition props.
<FormBuilder
title="Edit Profile"
titleAlign="left" // 'left' | 'center' | 'right' — default: 'left'
titlePosition="inside" // 'inside' | 'above' — default: 'inside'
...
/>
titlePosition="inside" — the heading renders inside the Paper container above the fields.
titlePosition="above" — the heading renders outside the Paper, useful when you control the container styling.
Custom Action Buttons
Replace the default Submit / Cancel / Reset buttons with your own layout via renderActions.
FormBuilder
import type { FormBuilderActionsParams } from 'mui-schema-form-builder';
<FormBuilder
onSubmit={fn}
onCancel={cancelFn}
onReset={resetFn}
renderActions={({ isSubmitting, submit, cancel, reset }: FormBuilderActionsParams) => (
<Stack direction="row" spacing={1} justifyContent="flex-end">
{cancel && <Button onClick={cancel}>Discard</Button>}
<Button variant="contained" onClick={submit} disabled={isSubmitting}>
Save Changes
</Button>
</Stack>
)}
/>
FormWizard
import type { FormWizardActionsParams } from 'mui-schema-form-builder';
<FormWizard
steps={steps}
schema={schema}
onSubmit={fn}
renderActions={({
isSubmitting, isFirstStep, isLastStep, next, back, submit,
}: FormWizardActionsParams) => (
<Stack direction="row" spacing={2} justifyContent="space-between" width="100%">
<Button onClick={back} disabled={isFirstStep}>← Back</Button>
{isLastStep
? <Button variant="contained" onClick={submit}>Finish</Button>
: <Button variant="contained" onClick={next}>Continue →</Button>
}
</Stack>
)}
/>
Validation
Pass 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'
...
/>
Conditional Fields
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',
},
];
Async Autocomplete
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 }));
},
}
FormBuilder Props
Prop
Type
Default
Description
fields
FieldConfig[]
required
Field configuration
schema
z.ZodType
required*
Zod validation schema (*or resolver)
resolver
Resolver
react-hook-form resolver (alternative to 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
onChange
(values: FieldValues) => void
Called on every field value change
onFieldChange
(name: string, value: unknown) => void
Called when a single field changes
submitText
string
'Submit'
Submit button label
cancelText
string
'Cancel'
Cancel button label
resetText
string
'Reset'
Reset button label
title
string
Optional form heading
titleAlign
'left' | 'center' | 'right'
'left'
Horizontal alignment of the title
titlePosition
'inside' | 'above'
'inside'
Whether the title is inside or above the Paper container
renderActions
(params: FormBuilderActionsParams) => ReactNode
Replace default buttons with a custom render
readOnly
boolean
false
Render all fields as display text
labels
FormBuilderLabels
Override built-in UI strings
spacing
number
2
MUI Grid spacing between fields
virtualize
boolean
false
Enable react-window for large forms
validationMode
ValidationMode
'onTouched'
When validation triggers
sx
SxProps
MUI sx prop for the outer Paper
TypeScript Tips
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 }],
[],
);
Accessibility
- 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-describedby linked to error messages
- Error messages have
role="alert" for screen reader announcement
- Radio groups and checkbox groups use
<fieldset> + <legend> (WCAG 1.3.1)
License
MIT Arjun Prakash
,
endAdornment: 'USD',
},
{
name: 'username',
label: 'Username',
type: FIELD_TYPE.TEXT,
startAdornment: '@',
},
]
Note: PASSWORD fields have their own fixed end adornment (the visibility toggle). Setting __INLINE_CODE_79__ on a PASSWORD field has no effect.
Form Title
Add a heading to __INLINE_CODE_80__ or __INLINE_CODE_81__ with the __INLINE_CODE_82__, __INLINE_CODE_83__, and __INLINE_CODE_84__ props.
__CODE_BLOCK_7__- __INLINE_CODE_85__ — the heading renders inside the Paper container above the fields.
- __INLINE_CODE_86__ — the heading renders outside the Paper, useful when you control the container styling.
Custom Action Buttons
Replace the default Submit / Cancel / Reset buttons with your own layout via __INLINE_CODE_87__.
FormBuilder
__CODE_BLOCK_8__FormWizard
__CODE_BLOCK_9__Validation
Pass any Zod schema. The library uses __INLINE_CODE_88__ internally:
__CODE_BLOCK_10__Control when validation runs:
__CODE_BLOCK_11__Conditional Fields
Only fields with __INLINE_CODE_89__ subscribe to form state changes. All other fields are isolated — typing in one field does not re-render its siblings.
__CODE_BLOCK_12__Async Autocomplete
Built-in 300ms debounce and stale-response protection. If a later search resolves before an earlier one, the earlier response is discarded.
__CODE_BLOCK_13__FormBuilder Props
| Prop | Type | Default | Description |
|---|---|---|---|
| __INLINE_CODE_90__ | __INLINE_CODE_91__ | required | Field configuration |
| __INLINE_CODE_92__ | __INLINE_CODE_93__ | required* | Zod validation schema (*or __INLINE_CODE_94__) |
| __INLINE_CODE_95__ | __INLINE_CODE_96__ | react-hook-form resolver (alternative to __INLINE_CODE_97__) | |
| __INLINE_CODE_98__ | __INLINE_CODE_99__ | required | Typed submit handler |
| __INLINE_CODE_100__ | __INLINE_CODE_101__ | Renders Cancel button when provided | |
| __INLINE_CODE_102__ | __INLINE_CODE_103__ | Renders Reset button when provided | |
| __INLINE_CODE_104__ | __INLINE_CODE_105__ | Called on every field value change | |
| __INLINE_CODE_106__ | __INLINE_CODE_107__ | Called when a single field changes | |
| __INLINE_CODE_108__ | __INLINE_CODE_109__ | __INLINE_CODE_110__ | Submit button label |
| __INLINE_CODE_111__ | __INLINE_CODE_112__ | __INLINE_CODE_113__ | Cancel button label |
| __INLINE_CODE_114__ | __INLINE_CODE_115__ | __INLINE_CODE_116__ | Reset button label |
| __INLINE_CODE_117__ | __INLINE_CODE_118__ | Optional form heading | |
| __INLINE_CODE_119__ | __INLINE_CODE_120__ | __INLINE_CODE_121__ | Horizontal alignment of the title |
| __INLINE_CODE_122__ | __INLINE_CODE_123__ | __INLINE_CODE_124__ | Whether the title is inside or above the Paper container |
| __INLINE_CODE_125__ | __INLINE_CODE_126__ | Replace default buttons with a custom render | |
| __INLINE_CODE_127__ | __INLINE_CODE_128__ | __INLINE_CODE_129__ | Render all fields as display text |
| __INLINE_CODE_130__ | __INLINE_CODE_131__ | Override built-in UI strings | |
| __INLINE_CODE_132__ | __INLINE_CODE_133__ | __INLINE_CODE_134__ | MUI Grid spacing between fields |
| __INLINE_CODE_135__ | __INLINE_CODE_136__ | __INLINE_CODE_137__ | Enable react-window for large forms |
| __INLINE_CODE_138__ | __INLINE_CODE_139__ | __INLINE_CODE_140__ | When validation triggers |
| __INLINE_CODE_141__ | __INLINE_CODE_142__ | MUI sx prop for the outer Paper |
TypeScript Tips
The generic propagates from schema → onSubmit automatically:
__CODE_BLOCK_14__Memoize your __INLINE_CODE_143__ array to prevent unnecessary recomputation of default values:
__CODE_BLOCK_15__Accessibility
- Every input has a proper __INLINE_CODE_144__ association — clicking the label focuses the input
- Required asterisk is __INLINE_CODE_145__ (visual cue only)
- Inputs have __INLINE_CODE_146__, __INLINE_CODE_147__, __INLINE_CODE_148__ linked to error messages
- Error messages have __INLINE_CODE_149__ for screen reader announcement
- Radio groups and checkbox groups use __INLINE_CODE_150__ + __INLINE_CODE_151__ (WCAG 1.3.1)
License
MIT Arjun Prakash