Zero-config form-to-payload pipeline for React and browser-based applications.
Discover controls directly from anHTMLFormElement, parse values, normalize, coerce, validate, transform, and build a clean payload — without maintaining duplicated form state.
Important
The core responsibility of this package is payload construction and validation. API submission is optional and should remain outside the core pipeline so the package can be used with any REST client, framework, or application architecture.
- No more
useStatefor form values. - No more manual
FormDataparsing — the pipeline handles it. - No more
Object.fromEntriesor manual nested-key handling. - Works with any UI library — MUI, Shadcn, Ant Design, or raw HTML.
- Lazy-first — add
nameattributes and it works. - Type-safe — optional Zod schema support for full type inference.
npm install @cassets/form-pipeline zod react
zodis optional — you only need it if you want schema validation.
The package can be used directly from a form submission event. It reads the form, validates the fields, and returns a structured payload.
import { useForm } from '@cassets/form-pipeline';
function LoginForm() {
const {
handleSubmit,
payload,
errors,
isValid,
} = useForm({
formRef: 'form#login',
});
const onSubmit = handleSubmit((result) => {
if (!result.success) return;
// API submission stays in the application layer.
fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(result.payload),
});
});
return (
<form id="login" onSubmit={onSubmit}>
<input
name="email"
type="email"
required
/>
<input
name="password"
type="password"
required
minLength={8}
/>
{errors?.email && <p>{errors.email}</p>}
{errors?.password && <p>{errors.password}</p>}
<button type="submit" disabled={!isValid}>
Login
</button>
</form>
);
}- The form controls are discovered from the DOM.
- Native HTML constraints are inspected.
- Raw values are extracted.
- Values are parsed and normalized.
- Values are coerced to the appropriate JavaScript types.
- Native validation is executed.
- Optional schema validation is executed.
- Custom transformations are applied.
- Nested payload paths are assembled.
- The final payload is returned to the application.
Note
The package should not require a URL or own the REST request lifecycle. Its primary output is a validated payload that the consuming application can send using fetch, Axios, TanStack Query, RTK Query, or any other client.
const {
handleSubmit,
getPayload,
validate,
reset,
setFieldError,
clearErrors,
payload,
raw,
errors,
isValid,
} = useForm({
formRef: string | React.RefObject<HTMLFormElement>,
schema?: ZodTypeAny,
options?: PipelineOptions,
});| Property | Type | Purpose |
|---|---|---|
handleSubmit |
(callback?: SubmitCallback<T>) => FormEventHandler |
Runs the full pipeline on form submission. |
getPayload |
() => PipelineResult<T> |
Runs the pipeline manually and returns the result. |
validate |
() => ValidationResult |
Validates the current form without submitting anything. |
reset |
() => void |
Clears cached payload, raw values, and errors. |
setFieldError |
(field: string, message: string) => void |
Adds an application-defined field error. |
clearErrors |
() => void |
Clears validation errors. |
payload |
T | null |
Last successfully built payload. |
raw |
Record<string, unknown> | null |
Last raw-value snapshot. |
errors |
Record<string, string> | null |
Field-level validation errors. |
isValid |
boolean |
Indicates whether the current result passed validation. |
Tip
A single PipelineResult<T> object is preferable to returning many unrelated booleans because it makes success, errors, raw data, transformed data, and payload easier to reason about.
Example:
type PipelineResult<T> =
| {
success: true;
payload: T;
raw: Record<string, unknown>;
errors: null;
}
| {
success: false;
payload: null;
raw: Record<string, unknown>;
errors: Record<string, string>;
};All options are optional — the defaults cover 90% of use cases.
{
normalize?: {
trim?: boolean; // default: true
emptyToUndefined?: boolean; // default: true
emptyToNull?: boolean; // default: false
};
coerce?: {
numbers?: boolean; // default: true (string → number)
booleans?: boolean; // default: true (checkbox → true/false)
dates?: 'string' | 'Date'; // default: 'string'
};
transform?: Record<string, (value: any) => any>;
nested?: boolean; // default: true (build nested object)
// Exclusions (all default to true)
skipUnderscore?: boolean; // skip names starting with '_'
skipAttributes?: string[]; // default: ['data-skip']
skipDisabled?: boolean; // default: true
stripEmpty?: boolean; // default: true (remove undefined/null keys)
ignoreFields?: string[]; // exact or wildcard (*)
shouldInclude?: (params: { fieldName: string; element: HTMLElement; rawValue: unknown }) => boolean;
fields?: Record<string, { skip?: boolean; transform?: (v: any) => any }>;
}The package should remain a pure form-processing pipeline:
HTMLFormElement
↓
Discover Controls
↓
Read HTML Constraints
↓
Extract RAW Values
↓
Parser
↓
Normalizer
↓
Coercer
↓
Validator
↓
Transformer
↓
Builder
↓
Payload
The package owns:
- DOM control discovery
- HTML constraint discovery
- raw value extraction
- parsing
- normalization
- coercion
- native validation
- optional schema validation
- custom transforms
- nested payload construction
- file detection
- output representation
The application owns:
- REST endpoint selection
- authentication headers
- API submission
- retry logic
- caching
- query invalidation
- navigation
- success notifications
- server-error handling
Important
Keeping API execution outside the package makes the library usable in React, Next.js client components, Vite applications, plain browser applications, and any REST-based architecture.
The pipeline runs on submit (or when you call getPayload()):
- Discover Controls — finds all
input,select,textarea(skipsdisabled,data-skip, and_prefixed names). - Read Constraints — reads
type,required,min,max,pattern,accept, etc. (used for native validation). - Extract RAW Values — gets the browser’s raw values (strings,
FileList, etc.). - Parser — interprets semantics (checkbox →
true/false, radio → selected value, select multiple →string[], file →File/File[]). - Normalizer — trims strings, converts empty to
undefined/null. - Coercer — converts
"25"→25,"true"→true, etc. - Validator — runs native HTML5 validation, then Zod schema (if provided).
- Transformer — applies custom per-field transformations.
- Builder — turns flat keys (
user.name) into a nested object ({ user: { name } }). - Payload — returns the final object (or
FormDataifsubmitAs: 'formdata').
The package should respect constraints already declared in the HTML rather than requiring the developer to repeat them in configuration.
| Control / Attribute | Expected behavior |
|---|---|
required |
Reject missing or empty values. |
type="email" |
Reject invalid email syntax using browser-compatible semantics. |
type="number" |
Parse numeric values and validate min, max, and step. |
minLength / maxLength |
Validate string length. |
pattern |
Validate the raw string against the element pattern. |
checkbox |
Produce a boolean for a single checkbox; grouped same-name checkboxes can produce arrays. |
radio |
Return the selected value; enforce required on the group. |
select |
Return the selected value. |
select[multiple] |
Return an array of selected values. |
date |
Return an ISO-like date string by default and enforce min / max. |
datetime-local |
Preserve the browser value unless a configured coercion is requested. |
file |
Return File / File[]; validate required, accept, and configured size rules. |
disabled |
Ignore by default. |
| unnamed controls | Ignore because they cannot contribute a payload key. |
Warning
HTML accept is primarily a picker hint in browsers. If strict file MIME/type enforcement is required, validate the selected File objects explicitly before returning a successful result.
RAW VALUE
↓
Parse
↓
Normalize
↓
Coerce
↓
Native HTML Constraint Validation
↓
Schema Validation
↓
Transform
↓
Build Payload
The order matters. For example, a numeric input should normally become a number before a Zod z.number() schema evaluates it.
Files cannot be represented as meaningful JSON values without an explicit strategy.
Recommended behavior:
type PayloadMode =
| 'json'
| 'formdata'
| 'auto';json— reject or explicitly serialize file-containing fields.formdata— convert the final structured payload intoFormData.auto— return JSON-compatible data when no files exist; returnFormDatawhen one or more file values are present.
Important
Avoid silently converting File objects to {} through JSON.stringify(). The pipeline should detect files and make the representation decision explicit.
import { useForm } from '@cassets/form-pipeline';
function ProfileForm() {
const { handleSubmit, isSubmitting } = useForm({
formRef: 'form#profile',
submit: async (data) => {
// data = { name, age, gender, photo }
await fetch('/api/profile', { method: 'POST', body: JSON.stringify(data) });
},
});
return (
<form id="profile" onSubmit={handleSubmit}>
<input name="name" required />
<input name="age" type="number" min={1} max={120} />
<label><input name="gender" type="radio" value="male" /> Male</label>
<label><input name="gender" type="radio" value="female" /> Female</label>
<input name="photo" type="file" accept="image/*" />
<button disabled={isSubmitting}>Save</button>
</form>
);
}Because the hook reads the DOM directly, it works with any library that renders native form controls.
import { useForm } from '@cassets/form-pipeline';
import { TextField, RadioGroup, FormControlLabel, Radio, Button } from '@mui/material';
function MUIForm() {
const { handleSubmit, errors, isSubmitting } = useForm({
formRef: 'form#mui',
url: '/api/users',
method: 'POST',
schema: z.object({ name: z.string().min(2), age: z.number().positive() }),
});
return (
<form id="mui" onSubmit={handleSubmit}>
<TextField name="name" label="Name" error={!!errors?.name} helperText={errors?.name} />
<TextField name="age" type="number" error={!!errors?.age} helperText={errors?.age} />
<RadioGroup name="gender">
<FormControlLabel value="male" control={<Radio />} label="Male" />
<FormControlLabel value="female" control={<Radio />} label="Female" />
</RadioGroup>
<Button type="submit" disabled={isSubmitting}>Submit</Button>
</form>
);
}import { useForm } from '@cassets/form-pipeline';
import { Input, Button, Label, RadioGroup, RadioGroupItem } from '@/components/ui';
function ShadcnForm() {
const { handleSubmit, errors, isSubmitting } = useForm({
formRef: 'form#shadcn',
url: '/api/users',
});
return (
<form id="shadcn" onSubmit={handleSubmit}>
<div><Label>Name</Label><Input name="name" required /></div>
{errors?.name && <p className="text-red-500">{errors.name}</p>}
<div><Label>Age</Label><Input name="age" type="number" /></div>
<RadioGroup name="gender">
<RadioGroupItem value="male" id="male" /><Label htmlFor="male">Male</Label>
<RadioGroupItem value="female" id="female" /><Label htmlFor="female">Female</Label>
</RadioGroup>
<Button disabled={isSubmitting}>Submit</Button>
</form>
);
}The hook automatically extracts files.
function FileUploadForm() {
const { handleSubmit } = useForm({
formRef: 'form#upload',
url: '/api/upload',
submitAs: 'formdata', // sends multipart/form-data
});
return (
<form id="upload" onSubmit={handleSubmit}>
<input name="avatar" type="file" accept="image/*" required />
<input name="gallery" type="file" multiple accept="image/*" />
<button type="submit">Upload</button>
</form>
);
}- Single file →
payload.avatar=Fileobject. - Multiple files →
payload.gallery=File[].
When submitAs: 'formdata', the hook calls toFormData(payload) internally and sends multipart/form-data.
- Prefix name with
_→_confirmPassword - Add
data-skipattribute →<input name="temp" data-skip /> - Use
disabled→<input name="debug" disabled />
ignoreFields: ['confirmPassword', 'meta.*'](glob patterns)shouldIncludecallback for dynamic conditions:
options: {
shouldInclude: ({ fieldName, rawValue }) => {
if (fieldName === 'newsletter' && rawValue === false) return false;
return true;
}
}Native HTML5 validation runs automatically (required, min, max, pattern, etc.).
Add a Zod schema for extra validation and type safety:
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirm: z.string().optional(),
}).refine(data => data.password === data.confirm, {
message: "Passwords don't match",
path: ['confirm'],
});
const { errors } = useForm({
schema,
// ...
});The errors object will contain messages from both native and Zod validation.
The DOM pipeline is browser-oriented because it starts from an HTMLFormElement.
The core parsing, normalization, coercion, validation, transform, and builder functions should nevertheless remain framework-independent so they can be tested independently and reused later.
import {
parseValue,
normalizeValue,
coerceValue,
validateValue,
buildNested,
} from '@cassets/form-pipeline/core';Note
A separate server-side raw-data API can be introduced later, but it should not distort the browser form API or require a fake HTMLFormElement.
All types are exported. When you provide a Zod schema, payload and data in the submit function are automatically inferred.
const schema = z.object({ email: z.string().email() });
const { handleSubmit } = useForm<z.infer<typeof schema>>({ schema, ... });-
DOM is the source of truth.
Form controls and native constraints should be discovered directly from the form. -
Zero duplicated form state by default.
Developers should not needuseStatefor every input just to construct a payload. -
Validation is layered.
Native HTML rules come first; optional schema validation adds domain-level rules. -
Payload construction is deterministic.
The same form state and options should always produce the same result. -
Transport is separate.
REST submission, retries, caching, authentication, and navigation belong to the application. -
Files are explicit.
The library must never pretend thatFileobjects are ordinary JSON. -
React is an adapter, not the core.
Core pipeline logic should remain testable without React.
src/
├── core/
│ ├── discover.ts
│ ├── constraints.ts
│ ├── extract.ts
│ ├── parser.ts
│ ├── normalizer.ts
│ ├── coercer.ts
│ ├── validator.ts
│ ├── transformer.ts
│ ├── builder.ts
│ ├── files.ts
│ └── pipeline.ts
├── react/
│ └── useForm.ts
├── adapters/
│ └── formData.ts
├── types/
│ └── index.ts
└── index.ts
export interface PipelineContext {
form: HTMLFormElement;
controls: FormControlDescriptor[];
raw: Record<string, unknown>;
parsed: Record<string, unknown>;
normalized: Record<string, unknown>;
coerced: Record<string, unknown>;
transformed: Record<string, unknown>;
errors: Record<string, string>;
}
export interface PipelineSuccess<T> {
success: true;
payload: T;
raw: Record<string, unknown>;
errors: null;
}
export interface PipelineFailure {
success: false;
payload: null;
raw: Record<string, unknown>;
errors: Record<string, string>;
}
export type PipelineResult<T> =
| PipelineSuccess<T>
| PipelineFailure;MIT