npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@juantroconisf/lib

v12.4.0

Published

A form validation library for HeroUI.

Readme

@juantroconisf/lib

npm version License: ISC

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. Spread on.* 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, and ControlledForm are 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 onValidationFailed callbacks.

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) or on.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:

  1. Schema defines shape, defaults, required flags, and validators.
  2. on.<component>(path) produces a stable, typed prop bag with value/onChange/isInvalid/isRequired/onBlur.
  3. <FieldError> reads from errors[path] for per-field rendering; results exposes 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