@anggitp/form-kit
v0.1.3
Published
Typed React Hook Form + Zod + MUI building blocks: createForm(), <Form>, and pre-wired field components (TextField, TextArea, Select, DatePicker, FileUpload, Switch).
Maintainers
Readme
@anggitp/form-kit
Typed form building blocks for React Hook Form + Zod + MUI: a useCreateForm()
hook, a <Form> provider component, and pre-wired field components
(FormTextField, FormTextArea, FormNumber, FormSelect, FormFileUpload,
FormSwitch, FormCheckbox, FormCheckboxGroup, FormRadioGroup,
FormToggleButtonGroup, FormDatePicker) that connect themselves to form
context and render MUI error/helper text automatically.
Install
npm install @anggitp/form-kit react-hook-form @hookform/resolvers zodPeer dependencies (must already be installed in the host app):
| Package | Why |
|---|---|
| react, react-dom | >=18 |
| @mui/material, @mui/icons-material | all inputs render on top of MUI |
| @emotion/react, @emotion/styled | MUI's styling engine |
| zod | schema validation, >=3.23 <4 (zod v4 restructured ZodObject's generics; not yet supported/tested) |
Optional (only needed if you use FormDatePicker, imported from the
/datepicker sub-path so everyone else's bundle stays smaller):
npm install @mui/x-date-pickers dayjsQuick start
import { z } from "zod";
import { Form, FormTextField, FormActions, SubmitButton } from "@anggitp/form-kit";
const schema = z.object({
fullName: z.string().min(1, "Full name is required."),
email: z.string().email("Please enter a valid email address."),
});
export function ContactForm() {
return (
<Form
schema={schema}
defaultValues={{ fullName: "", email: "" }}
onSubmit={async (data) => {
// data is fully typed as z.output<typeof schema>
await fetch("/api/contact", { method: "POST", body: JSON.stringify(data) });
}}
>
<FormTextField name="fullName" label="Full name" />
<FormTextField name="email" label="Email" />
<FormActions>
<SubmitButton>Send</SubmitButton>
</FormActions>
</Form>
);
}Need the date picker? Import it from the dedicated sub-path:
import { FormDatePicker } from "@anggitp/form-kit/datepicker";Field components
| Component | Value type | Notes |
|---|---|---|
| FormTextField | string | Plain MUI TextField |
| FormTextArea | string | Multiline TextField, rows prop |
| FormNumber | number \| undefined | Never a string — see FormNumber |
| FormSelect | string \| number | Custom searchable dropdown, not native <select> |
| FormCheckbox | boolean | Single checkbox |
| FormCheckboxGroup | (string \| number)[] | Multi-select checkboxes — see FormCheckboxGroup |
| FormRadioGroup | string \| number | Single-select radios — see FormRadioGroup |
| FormSwitch | string \| number | Exclusive ToggleButtonGroup, fixed brand styling |
| FormToggleButtonGroup | string \| number (or array with multiple) | Exclusive or multi-select — see FormToggleButtonGroup |
| FormFileUpload | File | Drag-and-drop upload |
| FormDatePicker | dayjs-compatible | From @anggitp/form-kit/datepicker only |
All of them must be rendered inside <Form> (or any FormProvider) — they
read control via useFormContext(), so name must match a key of the
schema passed to Form.
What's exported
Core
Form,useCreateForm,FormSchema,FormPropscreateForm— deprecated alias ofuseCreateForm, kept for backward compatibility. It callsuseForminternally, so it's a hook: call it unconditionally at the top of a component, same as any other hook.useFormValues,useAppFormStateInferInput,InferOutput,InferForm(Zod inference helpers)removeEmptyValues,trimStrings,normalizeEmail,normalizePhone,normalizePayload
Validation
VALIDATION_MESSAGES— single source of truth for error copy (override viamessagesprop, see below)- Rule builders:
requiredString,optionalString,email,phone,url,fullName,message,password,minString,maxString,dropdown,selectOption,year,gpa,attachmentFileRule createSchema— thinz.object()wrapper for consistency
Components
- Text-based:
FormTextField,FormTextArea,FormNumber - Choice:
FormSelect,FormCheckbox,FormCheckboxGroup,FormRadioGroup,FormSwitch,FormToggleButtonGroup - Other:
FormFileUpload,FormActions,FormSection,SubmitButton FormDatePicker— only from@anggitp/form-kit/datepicker
Every field component above also has an unstyled-of-form-context "base"
counterpart exported for cases where you want the input without React Hook
Form wiring (TextFieldBase, TextAreaBase, SelectBase, CheckboxBase,
CheckboxGroupBase, RadioBase, SwitchBase, ToggleButtonGroupBase) —
each field component is a thin useController/Controller wrapper around
its *Base counterpart, wiring up value/onChange/onBlur plus
error/helperText from the field's validation state.
Domain-specific schemas (
contactSchema,requestDemoSchema,apply-jobstep schemas) are not part of this package — see MIGRATION.md for why, and where they now live.
FormCheckbox
import { Form, FormCheckbox, requiredCheckbox } from "@anggitp/form-kit";
import { z } from "zod";
const schema = z.object({
acceptTerms: requiredCheckbox("the Terms of Service"),
});
<Form schema={schema} defaultValues={{ acceptTerms: false }} onSubmit={submit}>
<FormCheckbox name="acceptTerms" label="I agree to the Terms of Service" />
</Form>requiredCheckbox(label?) is a Zod boolean rule that fails validation unless
checked — use it for terms/consent checkboxes. A plain z.boolean().optional()
works fine for non-required checkboxes.
FormCheckboxGroup
Multi-select checkboxes. The field value is an array, so default it to []
rather than leaving it undefined.
import { Form, FormCheckboxGroup } from "@anggitp/form-kit";
import { z } from "zod";
const schema = z.object({
interests: z.array(z.enum(["design", "engineering", "sales"]))
.min(1, "Select at least one."),
});
<Form schema={schema} defaultValues={{ interests: [] }} onSubmit={submit}>
<FormCheckboxGroup
name="interests"
label="Areas of interest"
options={[
{ value: "design", label: "Design" },
{ value: "engineering", label: "Engineering" },
{ value: "sales", label: "Sales" },
]}
/>
</Form>FormRadioGroup
Single-select, rendered as MUI radios. onChange (and the field value) use
each option's original value type, not the raw string the DOM radio
input gives back — so numeric-valued options round-trip correctly.
import { Form, FormRadioGroup, selectOption } from "@anggitp/form-kit";
import { z } from "zod";
const schema = z.object({
experience: selectOption("Experience level", ["junior", "mid", "senior"]),
});
<Form schema={schema} defaultValues={{ experience: undefined }} onSubmit={submit}>
<FormRadioGroup
name="experience"
label="Experience level"
row
options={[
{ value: "junior", label: "Junior" },
{ value: "mid", label: "Mid" },
{ value: "senior", label: "Senior" },
]}
/>
</Form>FormNumber
Plain MUI TextField with type="number", but the field value is always a
real number | undefined — never a string. Pair with z.number() /
z.coerce.number(), not z.string(). min/max/step only affect the
native spinner/keyboard behavior; enforce the actual allowed range with
.min()/.max() on the Zod schema.
import { Form, FormNumber } from "@anggitp/form-kit";
import { z } from "zod";
const schema = z.object({
yearsOfExperience: z.number({ message: "Enter a number." })
.min(0, "Must be 0 or more.")
.max(50, "That doesn't look right."),
});
<Form schema={schema} defaultValues={{ yearsOfExperience: undefined }} onSubmit={submit}>
<FormNumber name="yearsOfExperience" label="Years of experience" min={0} max={50} step={1} />
</Form>FormToggleButtonGroup
Segmented-control style choice input built on MUI's ToggleButtonGroup.
Single-select (exclusive) by default; pass multiple for a checkbox-like
multi-select rendered as toggle buttons instead.
import { Form, FormToggleButtonGroup } from "@anggitp/form-kit";
import { z } from "zod";
const schema = z.object({
plan: z.enum(["monthly", "yearly"]),
addOns: z.array(z.enum(["support", "analytics"])).default([]),
});
<Form schema={schema} defaultValues={{ plan: "monthly", addOns: [] }} onSubmit={submit}>
<FormToggleButtonGroup
name="plan"
label="Billing"
options={[
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly" },
]}
/>
<FormToggleButtonGroup
name="addOns"
label="Add-ons"
multiple
options={[
{ value: "support", label: "Priority support" },
{ value: "analytics", label: "Analytics" },
]}
/>
</Form>
FormSwitch/SwitchBasepredates this component and also renders as an exclusiveToggleButtonGroup, but with a fixed brand-colored style and nomultipleoption. It's kept for backward compatibility; preferFormToggleButtonGroupfor new fields — it's theme-driven (no hard-coded colors) and supports multi-select.
Customizing validation copy
VALIDATION_MESSAGES ships in English. To localize or reword, don't fork the
package — pass overrides into the rule builders (requiredString("Nama", …))
or compose your own VALIDATION_MESSAGES-shaped object and pass it through
rules.ts's upcoming configureMessages() (tracked in
#TODO).
Roadmap
- [ ]
configureMessages()for i18n / message overrides without forking - [ ] Theming hook for
FormFileUpload's hard-coded colors - [ ] Storybook + visual regression tests
- [ ]
RESUME_OR_LINK_REQUIRED/gpa/yearrules are domain-specific — consider a@anggitp/form-kit/recipesentry point instead of core
Contributing
See CONTRIBUTING.md. PRs adding new field components or
validation rules are welcome — keep new components consistent with the
existing Controller + useFormContext pattern used throughout components/fields/.
