Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@cassets/form-pipeline

Zero-config form-to-payload pipeline for React and browser-based applications.
Discover controls directly from an HTMLFormElement, parse values, normalize, coerce, validate, transform, and build a clean payload — without maintaining duplicated form state.

npm version License: MIT

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.


Why?

  • No more useState for form values.
  • No more manual FormData parsing — the pipeline handles it.
  • No more Object.fromEntries or manual nested-key handling.
  • Works with any UI library — MUI, Shadcn, Ant Design, or raw HTML.
  • Lazy-first — add name attributes and it works.
  • Type-safe — optional Zod schema support for full type inference.

Install

npm install @cassets/form-pipeline zod react

zod is optional — you only need it if you want schema validation.


Quick Start

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>
  );
}

What happens on submit?

  1. The form controls are discovered from the DOM.
  2. Native HTML constraints are inspected.
  3. Raw values are extracted.
  4. Values are parsed and normalized.
  5. Values are coerced to the appropriate JavaScript types.
  6. Native validation is executed.
  7. Optional schema validation is executed.
  8. Custom transformations are applied.
  9. Nested payload paths are assembled.
  10. 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.


API

useForm(config)

const {
  handleSubmit,
  getPayload,
  validate,
  reset,
  setFieldError,
  clearErrors,
  payload,
  raw,
  errors,
  isValid,
} = useForm({
  formRef: string | React.RefObject<HTMLFormElement>,
  schema?: ZodTypeAny,
  options?: PipelineOptions,
});

Suggested return contract

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>;
    };

PipelineOptions

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 }>;
}

Architecture

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

Responsibility boundary

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.


How It Works

The pipeline runs on submit (or when you call getPayload()):

  1. Discover Controls — finds all inputselecttextarea (skips disableddata-skip, and _ prefixed names).
  2. Read Constraints — reads typerequiredminmaxpatternaccept, etc. (used for native validation).
  3. Extract RAW Values — gets the browser’s raw values (strings, FileList, etc.).
  4. Parser — interprets semantics (checkbox → true/false, radio → selected value, select multiple → string[], file → File/File[]).
  5. Normalizer — trims strings, converts empty to undefined/null.
  6. Coercer — converts "25" → 25"true" → true, etc.
  7. Validator — runs native HTML5 validation, then Zod schema (if provided).
  8. Transformer — applies custom per-field transformations.
  9. Builder — turns flat keys (user.name) into a nested object ({ user: { name } }).
  10. Payload — returns the final object (or FormData if submitAs: 'formdata').

Native Validation Rules

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.

Validation order

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.


File Handling

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 into FormData.
  • auto — return JSON-compatible data when no files exist; return FormData when 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.


Examples

1. Raw HTML (Vanilla)

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>
  );
}

2. Component Libraries (MUI / Shadcn / Ant Design)

Because the hook reads the DOM directly, it works with any library that renders native form controls.

With MUI

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>
  );
}

With Shadcn/ui

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>
  );
}

3. File Uploads (Single / Multiple)

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 = File object.
  • Multiple files → payload.gallery = File[].

When submitAs: 'formdata', the hook calls toFormData(payload) internally and sends multipart/form-data.


4. Excluding Fields (Lazy & Advanced)

Lazy Methods (Zero Config)

  • Prefix name with _ → _confirmPassword
  • Add data-skip attribute → <input name="temp" data-skip />
  • Use disabled → <input name="debug" disabled />

Configuration Methods

  • ignoreFields: ['confirmPassword', 'meta.*'] (glob patterns)
  • shouldInclude callback for dynamic conditions:
options: {
  shouldInclude: ({ fieldName, rawValue }) => {
    if (fieldName === 'newsletter' && rawValue === false) return false;
    return true;
  }
}

5. Validation (Native + Zod)

Native HTML5 validation runs automatically (requiredminmaxpattern, 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.


6. Framework Independence

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.


TypeScript

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, ... });

Design Principles

  1. DOM is the source of truth.
    Form controls and native constraints should be discovered directly from the form.

  2. Zero duplicated form state by default.
    Developers should not need useState for every input just to construct a payload.

  3. Validation is layered.
    Native HTML rules come first; optional schema validation adds domain-level rules.

  4. Payload construction is deterministic.
    The same form state and options should always produce the same result.

  5. Transport is separate.
    REST submission, retries, caching, authentication, and navigation belong to the application.

  6. Files are explicit.
    The library must never pretend that File objects are ordinary JSON.

  7. React is an adapter, not the core.
    Core pipeline logic should remain testable without React.


Proposed Package Structure

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

Core execution contract

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;

License

MIT

About

Zero-config TypeScript form-to-payload pipeline for React and browser apps. Discovers HTML controls, validates native constraints, coerces values, handles files, builds nested payloads, supports Zod, and generates OpenAPI-compatible schemas.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages