@cassets/form-pipeline
v0.1.1
Published
DOM-first form-to-payload pipeline for React and browser apps with native validation, coercion, nested payloads, files, and optional Zod validation.
Maintainers
Readme
@cassets/form-pipeline
DOM-first form-to-payload processing for React and browser applications.
@cassets/form-pipeline reads the form you already rendered, applies native HTML validation, normalizes and coerces values, builds nested payloads, handles files, and optionally validates with Zod. It is designed for uncontrolled or lightly controlled forms where the DOM is the source of truth.
Why this package?
Traditional React forms often duplicate browser state in useState, repeat validation rules in multiple layers, and hand-build API payloads. This package uses the semantics already present in HTMLFormElement controls.
HTMLFormElement
-> discover controls
-> read raw values
-> normalize
-> coerce from HTML semantics
-> native validation
-> transforms
-> nested builder
-> optional Zod validation
-> JSON object or FormDataThe package can build a payload only, or the React hook can optionally call your submit function / URL. For larger applications, keeping network submission in your API layer is usually the cleanest boundary.
Installation
npm install @cassets/form-pipeline reactOptional Zod validation (any compatible safeParse schema can be used):
npm install zodReact >=17 is a peer dependency. Zod >=3 is optional.
Quick start: payload only
import { useRef } from 'react';
import { formPipe } from '@cassets/form-pipeline';
export function ProfileForm() {
const ref = useRef<HTMLFormElement>(null);
return (
<form
ref={ref}
onSubmit={(event) => {
event.preventDefault();
if (!ref.current) return;
const result = formPipe(ref.current);
if (!result.success) {
console.error(result.errors);
return;
}
console.log(result.payload);
}}
>
<input name="profile.name" required />
<input name="profile.age" type="number" min={1} />
<button>Save</button>
</form>
);
}For input values profile.name="Vivek" and profile.age="37", the payload is:
{
"profile": {
"name": "Vivek",
"age": 37
}
}React hook
import { useRef } from 'react';
import { useForm } from '@cassets/form-pipeline';
export function LoginForm() {
const formRef = useRef<HTMLFormElement>(null);
const { handleSubmit, errors, isSubmitting, isSuccess } = useForm({
formRef,
submit: async (payload) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
},
});
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="email" type="email" required />
{errors?.email && <p>{errors.email}</p>}
<input name="password" type="password" required minLength={8} />
{errors?.password && <p>{errors.password}</p>}
<button disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
{isSuccess && <p>Signed in</p>}
</form>
);
}You can use either a React ref or a selector:
useForm({ formRef: 'form#account' });Core API
formPipe(form, options?, schema?)
Runs the full pipeline synchronously.
const result = formPipe<MyPayload>(formElement, options, schema);Result:
interface PipelineResult<T> {
success: boolean;
payload: T;
errors: Record<string, string> | null;
rawValues: Record<string, unknown>;
}rawValues contains values as read from controls before normalization/coercion/transforms. payload is the final value.
toFormData(payload)
Converts a nested payload into FormData using dotted keys.
import { toFormData } from '@cassets/form-pipeline';
const formData = toFormData({
profile: { name: 'Vivek' },
avatar: file,
});
// profile.name -> Vivek
// avatar -> FileuseForm(config)
const {
handleSubmit,
getPayload,
reset,
setFieldError,
clearErrors,
payload,
errors,
isSubmitting,
isSuccess,
isError,
} = useForm({
formRef,
schema,
submit,
url,
method,
submitAs,
options,
onSuccess,
onError,
resetOnSuccess,
});getPayload() validates the current form and returns either the payload, FormData, or null when validation fails.
HTML control behavior
Text, email, password, textarea
<input name="name" required />
<input name="email" type="email" required />
<textarea name="bio" maxlength="500"></textarea>Strings are trimmed by default. Empty strings become undefined by default.
Number and range
<input name="quantity" type="number" min="1" max="99" />
<input name="score" type="range" min="0" max="10" />number and range values are converted to JavaScript numbers by default. Numeric-looking text fields remain strings.
Single checkbox
<input name="termsAccepted" type="checkbox" required />Produces a boolean:
{ "termsAccepted": true }Checkbox group
<label><input type="checkbox" name="roles" value="admin" /> Admin</label>
<label><input type="checkbox" name="roles" value="editor" /> Editor</label>
<label><input type="checkbox" name="roles" value="viewer" /> Viewer</label>Checked values become an array:
{ "roles": ["admin", "viewer"] }Radio group
<label><input type="radio" name="plan" value="free" /> Free</label>
<label><input type="radio" name="plan" value="pro" /> Pro</label>Only the checked radio contributes a value.
Select and multi-select
<select name="country" required>
<option value="IN">India</option>
<option value="GB">United Kingdom</option>
</select>
<select name="skills" multiple>
<option value="typescript">TypeScript</option>
<option value="react">React</option>
<option value="node">Node.js</option>
</select>A multi-select produces a string array.
Date controls
Dates remain strings by default, which is usually safest for APIs.
<input name="startDate" type="date" />To produce Date objects:
formPipe(form, {
coerce: { dates: 'Date' },
});File and multiple files
<input name="avatar" type="file" accept="image/*" />
<input name="documents" type="file" multiple />Single-file controls produce File; multiple-file controls produce File[].
For transport use FormData:
const { handleSubmit } = useForm({
formRef,
submitAs: 'formdata',
submit: async (body) => fetch('/api/profile', { method: 'POST', body }),
});Do not manually set the multipart Content-Type header; the browser adds its boundary.
Nested objects and arrays
Dot and bracket notation are supported:
<input name="user.name" value="Vivek" />
<input name="user.address.city" value="Chennai" />
<input name="items[0].sku" value="A-100" />
<input name="items[0].quantity" type="number" value="2" />
<input name="items[1].sku" value="B-200" />Produces:
{
"user": {
"name": "Vivek",
"address": { "city": "Chennai" }
},
"items": [
{ "sku": "A-100", "quantity": 2 },
{ "sku": "B-200" }
]
}Dangerous object path segments such as __proto__, constructor, and prototype are rejected by the nested builder.
Native HTML validation
The browser remains the first validation layer:
<input name="username" required minlength="3" maxlength="30" />
<input name="email" type="email" required />
<input name="age" type="number" min="18" max="120" />
<input name="code" pattern="[A-Z]{3}-[0-9]{4}" />Invalid controls are returned in errors using the browser's validationMessage.
Zod validation
import { z } from 'zod';
import { useForm } from '@cassets/form-pipeline';
const schema = z.object({
email: z.string().email(),
age: z.number().int().min(18),
});
const form = useForm<z.infer<typeof schema>>({
formRef,
schema,
});Native validation runs first at control level; Zod validates the assembled payload.
Normalization and coercion
Defaults:
{
normalize: {
trim: true,
emptyToUndefined: true,
emptyToNull: false,
},
coerce: {
numbers: true,
booleans: true,
dates: 'string',
},
nested: true,
skipUnderscore: true,
skipAttributes: ['data-skip'],
skipDisabled: true,
stripEmpty: true,
}Disable behavior explicitly:
const result = formPipe(form, {
normalize: { trim: false, emptyToUndefined: false },
coerce: { numbers: false },
nested: false,
stripEmpty: false,
});Transform fields
const result = formPipe(form, {
transform: {
email: (value) => String(value).toLowerCase(),
},
fields: {
amount: {
transform: (value) => Math.round(Number(value) * 100),
},
},
});Field-specific transforms run before the global transform map for the same field.
Excluding controls
data-skip
<input name="uiSearch" data-skip />Leading underscore
<input name="_csrf_display_only" />Names beginning with _ are skipped by default.
Ignore exact names or wildcard patterns
formPipe(form, {
ignoreFields: ['debug', 'internal.*', 'items.*.temporary'],
});Per-field skip
formPipe(form, {
fields: {
internalToken: { skip: true },
},
});Custom inclusion logic
formPipe(form, {
shouldInclude: ({ fieldName, element, rawValue }) => {
return !element.closest('[data-disabled-section]');
},
});Direct URL submission
For small applications, useForm can own the final fetch call:
const form = useForm({
formRef,
url: '/api/users',
method: 'POST',
onSuccess: (response) => console.log(response),
onError: (error) => console.error(error),
resetOnSuccess: true,
});For authentication, retries, caching, request cancellation, or application-wide error handling, prefer the submit callback and your own HTTP layer.
Application-defined server errors
const { setFieldError, clearErrors } = useForm({ formRef });
setFieldError('email', 'This email is already registered');
clearErrors();Reset
reset() calls the native form reset() and clears hook state. resetOnSuccess: true does the same after a successful hook-owned submission.
Browser and SSR notes
The processing APIs depend on browser DOM types such as HTMLFormElement, File, and FormData. Run them client-side. Importing types is safe, but do not execute the form pipeline during server rendering.
Security model
- The package does not send credentials unless you configure
useFormsubmission. - Native browser validation is a UX/client-side guard, not a server security boundary.
- Always validate and authorize again on the server.
- Nested path construction blocks prototype-pollution path segments.
- File
acceptis advisory; validate file type, size, and content on the server.
Package family
@cassets/http-client— HTTP requests and chunked upload orchestration.@cassets/cloud— signed-upload URL adapters for cloud storage.
Project links
- ClusterAssets GitHub: https://github.com/clusterassets
- ClusterAssets LinkedIn: https://www.linkedin.com/company/clusterassets
- Creator GitHub: https://github.com/diskhacker
- Creator LinkedIn: https://www.linkedin.com/in/kp-vivek-rao-bhosale/
Contributing and security
See CONTRIBUTING.md and SECURITY.md.
License
MIT © Vivek Rao Bhosale / ClusterAssets.
