@juantroconisf/lib
v12.4.0
Published
A form validation library for HeroUI.
Readme
@juantroconisf/lib
A type-safe, zero-boilerplate form library for React + HeroUI v3. It bridges a Yup schema directly to HeroUI component props through a polymorphic on.* API — state, validation, error messages, dirty tracking, and ID-based array operations all derive from the schema.
Why this library?
- Zero wiring. No manual
value/onChange/onBlur. Spreadon.*and you're done. - Schema is the source of truth. State shape, types, defaults, required indicators, and error messages are all inferred from your Yup schema.
- Stable references.
on,helpers, andControlledFormare referentially stable — inputs do not lose focus across re-renders. - O(1) array ops. Items are tracked by ID via an internal
indexMap, so add/remove/reorder is constant-time. - Three tiers of validation hooks. Global, per-form, and per-call
onValidationFailedcallbacks.
Table of Contents
Quick Start
Install
pnpm add @juantroconisf/lib yup@heroui/react and react are peer dependencies.
A minimal form
import { useForm } from "@juantroconisf/lib";
import { string, boolean } from "yup";
import { TextField, Label, Input, FieldError, Switch, Button } from "@heroui/react";
const MyForm = () => {
const { on, errors, ControlledForm } = useForm({
fullName: string().required().default(""),
darkMode: boolean().default(false),
});
return (
<ControlledForm onSubmit={(data) => console.log(data)}>
<TextField {...on.input("fullName")}>
<Label>Full Name</Label>
<Input />
<FieldError>{errors.fullName}</FieldError>
</TextField>
<Switch {...on.switch("darkMode")}>Dark Mode</Switch>
<Button type="submit" color="primary">Submit</Button>
</ControlledForm>
);
};That's the whole pattern: every schema field gets a default, every field renders as a HeroUI v3 wrapper with {...on.x(path)} spread, and <FieldError> is a direct child that reads from errors[path].
How-to Guides
Nested objects
Dot notation reaches arbitrarily deep, and the path is type-checked against your schema.
<TextField {...on.input("settings.profile.username")}>
<Label>Username</Label>
<Input />
<FieldError>{errors["settings.profile.username"]}</FieldError>
</TextField>Dynamic arrays of objects
Items are tracked by id (override with arrayIdentifiers). Errors are keyed arrayName.itemId.fieldName.
{state.users.map((user) => (
<div key={user.id}>
<TextField {...on.input("users.name", user.id)}>
<Label>Name</Label>
<Input />
<FieldError>{errors[`users.${user.id}.name`]}</FieldError>
</TextField>
<Button onPress={() => helpers.removeById("users", user.id)}>Remove</Button>
</div>
))}
<Button onPress={() => helpers.addItem("users", { id: crypto.randomUUID(), name: "" })}>
Add User
</Button>Custom identifier:
useForm(schema, { arrayIdentifiers: { users: "uuid" } });Primitive arrays
Primitive array items are keyed by index with a literal @ prefix: arrayName.@<index>.
{state.tags.map((_, i) => (
<TextField key={i} {...on.input("tags", i)}>
<Label>Tag</Label>
<Input />
<FieldError>{errors[`tags.@${i}`]}</FieldError>
</TextField>
))}N-level deep structures
For arrays inside objects inside arrays, alternate structural segments with identifiers.
<TextField
{...on.input("groups", groupId, "members", memberId, "name")}
>
<Label>Member name</Label>
<Input />
</TextField>This keeps indexMap lookups O(1) at every level.
Manual updates
For state changes triggered outside HeroUI (WebSocket, API response, computed value):
const { onFieldChange, onArrayItemChange, onSelectionChange, onArraySelectionChange } = useForm(schema);
onFieldChange("settings.theme", "dark");
onArrayItemChange({ at: "users.name", id: userId, value: "Alice" });
onSelectionChange("country", "us");
onArraySelectionChange({ at: "users.role", id: userId, value: "admin" });Multi-step validation
Validate only a slice of the form between steps:
const { isValid, results } = await validateFields(["step1.email", "step1.password"]);
if (!isValid) return; // surface results in a toast or summary
goToStep(2);Validate a single array item before allowing "add another":
const { isValid } = await helpers.validateItem("users", currentUserId);
if (!isValid) return;
helpers.addItem("users", blankUser());Handling validation failures
Three independent tiers, all receive (response: ValidationResponse, e: React.FormEvent):
// 1. Global — fires for every submission path
const { ControlledForm, onFormSubmit } = useForm(schema, {
onValidationFailed: (res) => toast.error(`${res.errors.length} field(s) invalid`),
});
// 2. Per-form — fires alongside the global handler
<ControlledForm
onSubmit={save}
onValidationFailed={(res) => focusFirst(res.errors[0])}
/>
// 3. Per-call — when using the functional submit wrapper
<form onSubmit={onFormSubmit(save, (res) => analytics.track("form_invalid", res))}>
{/* ... */}
</form>Reference
useForm(schema, options?)
schema is an object whose values are Yup schemas (or raw defaults). State type is inferred via InferState<typeof schema>.
Every schema field must call .default(...) — without it, components flip between uncontrolled/controlled and React will warn.
Options (FormOptions)
| Option | Type | Default | Purpose |
| :--- | :--- | :--- | :--- |
| arrayIdentifiers | { [arrayPath]: keyof ItemElement } | "id" | Override the per-array primary key. |
| onFormSubmit | (data, e) => void | — | Called by ControlledForm after validation passes. |
| onValidationFailed | (response, e) => void | — | Global Tier-1 failure handler (fires on every submit path). |
| resetOnSubmit | boolean | false | Reset state + metadata after successful ControlledForm submit. |
| keepValues | (keyof State)[] | — | Fields to preserve across reset. |
Return value (UseFormResponse)
| Property | Type | Notes |
| :--- | :--- | :--- |
| state | O | Live, typed state. |
| setState | Dispatch<SetStateAction<O>> | Escape hatch — prefer setters below. |
| metadata | Map<string, FieldMetadata> | Per-field { isTouched, isInvalid, errorMessage, label? }. Leaf-keys only. |
| errors | Partial<Record<string, string>> | Touched + invalid fields keyed by composite path. Drives <FieldError>. |
| on | OnMethods<O> | Component bindings (see below). |
| helpers | HelpersFunc<O> | Array + validation helpers. |
| onFieldChange(path, value) | manual scalar/nested setter. |
| onArrayItemChange({ at, id, value }) | manual object-array setter. |
| onSelectionChange(id, value) | manual scalar selection. |
| onArraySelectionChange({ at, id, value }) | manual object-array selection. |
| onFieldBlur(id) | Force-touch + validate a field. |
| isDirty | boolean | True if any field has been touched. |
| onFormReset(options?) | Reset state + metadata; honors keepValues. |
| onFormSubmit(fn, onValidationFailed?) | Returns an (e) => Promise<void> that validates then runs fn. |
| validateAll() | Promise<ValidationResponse> | |
| validateFields(paths) | Promise<ValidationResponse> | Partial validation. |
| ControlledForm | React.ComponentType | HeroUI <Form> that validates on submit. |
on.* bindings
All methods return { id, name, isInvalid, isRequired, onBlur } plus the value/onChange pair for that component family. isRequired is derived from .required() in the Yup schema.
| Method | Component(s) | Value type |
| :--- | :--- | :--- |
| on.input(path, ...) | TextField + Input/Textarea | value: V, onChange: (V) => void |
| on.numberInput(path, ...) | NumberField | value: number (NaN when empty), onChange: (number) => void |
| on.select(scalar/item path, ...) | Select (single) | value: Key \| null, onChange: (Key \| null) => void |
| on.select(arrayKey) | Select (multi) | value: Key[], onChange: (Key[]) => void |
| on.autocomplete(path, ...) | Autocomplete | value: Key \| null, onChange: (Key \| null) => void |
| on.checkbox(path, ...) | Checkbox | isSelected: boolean, onChange: (boolean) => void |
| on.switch(path, ...) | Switch | isSelected, onChange |
| on.radio(path, ...) | RadioGroup | value: string, onChange: (string) => void |
Key is HeroUI v3's string | number. Numeric schemas pass through as numbers (since v12.0.2).
Variadic path forms
- Scalar/nested:
on.input("settings.theme") - Object array (two forms):
on.input("users.name", id)oron.input("users", id, "name") - Primitive array:
on.input("tags", index) - N-level:
on.input("groups", gid, "members", mid, "name")
helpers.*
| Helper | Sync/Async | Description |
| :--- | :--- | :--- |
| addItem(path, item, index?) | sync | Append, or insert at index. |
| removeById(path, id) | sync, O(1) | Remove + clean child metadata. |
| removeItemByIndex(path, index) | sync | Index variant. |
| updateById(path, id, partial) | sync | Shallow merge. |
| updateByIndex(path, index, partial) | sync | Index variant. |
| getItemById(path, id) | sync, O(1) | Current item or undefined. |
| moveById(path, fromId, toId) | sync | Preserves focus + state. |
| moveItemByIndex(path, from, to) | sync | Index variant. |
| validateItem(path, id) | async | Validates the whole item by id. |
| validateAll() | async | Mirror of top-level. |
| validateFields(paths) | async | Mirror of top-level. |
ValidationResponse
interface ValidationResponse {
isValid: boolean;
errors: string[]; // composite keys
results: ErrorResult[]; // structured + human-readable
}
interface ErrorResult {
id: string; // e.g. "users.abc.name"
label: string; // DOM-captured (aria-labelledby > <label for> > prettified path)
message: string;
}Render summaries from results (it carries human-readable labels); render per-field messages from errors.
Type utilities
// Derive a submit handler signature from a schema literal
const schema = { email: string().required().default("") };
const onSubmit: InferSubmitHandler<typeof schema> = (data, e) => { /* data.email typed */ };
// Or extract the handler type from the returned ControlledForm
const form = useForm(schema);
const onSubmit2: FormSubmit<typeof form.ControlledForm> = (data, e) => { /* ... */ };Composite key format
| Shape | Key format | Example |
| :--- | :--- | :--- |
| Scalar | fieldName | "email" |
| Nested | parent.child | "settings.theme" |
| Object array item | arrayName.itemId.fieldName | "users.abc.name" |
| Primitive array item | arrayName.@<index> | "tags.@0" |
metadata.get(...) only resolves leaf keys. metadata.get("users.abc") is always undefined — call helpers.validateItem("users", "abc") instead.
Explanation
Bridge pattern
The schema is the single source of truth. on.* methods translate that truth into the prop contract each HeroUI v3 component expects:
- Schema defines shape, defaults, required flags, and validators.
on.<component>(path)produces a stable, typed prop bag withvalue/onChange/isInvalid/isRequired/onBlur.<FieldError>reads fromerrors[path]for per-field rendering;resultsexposes a structured summary view.
The result is one declaration per field — no manual sync code, and TypeScript verifies every path against the schema.
Why composite keys and indexMap
Arrays are tracked by their item identifier (configurable via arrayIdentifiers). An internal indexMap resolves id → array index in O(1), so add/remove/move/update/getById are all constant-time regardless of list length. Composite keys like users.abc.name are stable across reorders, which is what keeps inputs focused and React keys consistent.
Localization
Validation messages are localized by reading the LOCALE cookie (en or es):
document.cookie = "LOCALE=es; path=/;";License
ISC © Juan T
