react ~5 KB 0 deps v0.1.0 β†— GitHub β†—

@mshafiqyajid/react-form

Headless useForm hook + styled Form component. Sync/async validation, validateOn strategies, controlled mode, full ARIA wiring, error animations, and zero runtime dependencies.

Playground #

e.g. user@example.com
At least 8 characters

Use error@example.com to simulate a server error.

Install #

npm install @mshafiqyajid/react-form

Quick start β€” styled #

import { Form } from "@mshafiqyajid/react-form/styled";
import "@mshafiqyajid/react-form/styles.css";

<Form
  defaultValues={{ email: "", password: "" }}
  validate={(values) => {
    const errors: Record<string, string> = {};
    if (!values.email) errors.email = "Email is required";
    if (String(values.password).length < 8)
      errors.password = "At least 8 characters";
    return errors;
  }}
  onSubmit={async (values) => {
    await login(values.email as string, values.password as string);
  }}
>
  {(form) => (
    <>
      <Form.Field name="email" label="Email" hint="Work email preferred" required>
        <input type="email" {...form.register("email", { required: true })} />
      </Form.Field>

      <Form.Field name="password" label="Password" required>
        <input type="password" {...form.register("password", { required: true })} />
      </Form.Field>

      <Form.Submit>Sign in</Form.Submit>
    </>
  )}
</Form>

Headless #

import { useForm } from "@mshafiqyajid/react-form";

const { register, handleSubmit, formState } = useForm({
  defaultValues: { email: "", password: "" },
  validate: (values) => {
    const errors: Record<string, string> = {};
    if (!values.email) errors.email = "Email is required";
    if (!values.password) errors.password = "Password is required";
    return errors;
  },
  onSubmit: async (values, helpers) => {
    await login(values.email as string, values.password as string);
    helpers.reset();
  },
});

return (
  <form onSubmit={handleSubmit} noValidate aria-busy={formState.isSubmitting}>
    <div>
      <label htmlFor="rfrm-field-email">Email</label>
      <input type="email" {...register("email")} />
      {formState.errors.email && (
        <span role="alert">{formState.errors.email}</span>
      )}
    </div>
    <div>
      <label htmlFor="rfrm-field-password">Password</label>
      <input type="password" {...register("password")} />
      {formState.errors.password && (
        <span role="alert">{formState.errors.password}</span>
      )}
    </div>
    <button type="submit" disabled={formState.isSubmitting}>
      {formState.isSubmitting ? "Signing in…" : "Sign in"}
    </button>
  </form>
);

Async validation #

<Form
  defaultValues={{ username: "" }}
  validate={async (values) => {
    const taken = await checkUsernameTaken(values.username as string);
    return taken ? { username: "Username is already taken" } : {};
  }}
  validateOn="blur"
  revalidateOn="change"
  onSubmit={handleSubmit}
>
  {(form) => (
    <>
      <Form.Field name="username" label="Username" required>
        <input {...form.register("username", { required: true })} />
      </Form.Field>
      <Form.Submit>Create account</Form.Submit>
    </>
  )}
</Form>

Controlled mode #

const [formValues, setFormValues] = useState({ search: "" });

<Form
  values={formValues}
  onSubmit={(values) => console.log(values)}
>
  {(form) => (
    <>
      <Form.Field name="search" label="Search">
        <input
          {...form.register("search")}
          onChange={(e) => setFormValues({ search: e.target.value })}
        />
      </Form.Field>
      <Form.Submit>Search</Form.Submit>
    </>
  )}
</Form>

Programmatic control #

const { setValue, setError, clearErrors, reset, watch, formState } = useForm({
  defaultValues: { role: "viewer" },
});

// Read current value
const role = watch("role");

// Set a value programmatically
setValue("role", "admin");

// Set a server-side error
setError("email", "This email is already in use");

// Clear a specific error
clearErrors("email");

// Clear all errors
clearErrors();

// Reset to defaults
reset();

// Reset to different values
reset({ role: "editor" });

API #

Full prop and option reference for all react-form exports.

useForm options #

OptionTypeDefaultDescription
defaultValuesRecord<string, unknown>{}Initial uncontrolled values
valuesRecord<string, unknown>β€”Fully controlled values (external source of truth)
validate(values) => Record<string, string> | Promise<…>β€”Returns a map of field name β†’ error message
validateOn"blur" | "change" | "submit""submit"When to run validation for the first time
revalidateOn"blur" | "change""change"When to re-validate a field that already has an error
onSubmit(values, helpers) => void | Promise<void>β€”Called only when validation passes. Receives current values and helpers.
onError(errors) => voidβ€”Called when submit is blocked by validation errors

formState #

FieldTypeDescription
isSubmittingbooleanTrue while the onSubmit promise is pending
isValidbooleanNo validation errors currently present
isDirtybooleanAny value differs from defaultValues
errorsRecord<string, string>Current field errors (field name β†’ message)
touchedFieldsRecord<string, boolean>Fields that have been blurred at least once
dirtyFieldsRecord<string, boolean>Fields whose current value differs from their default

Form.Field props #

PropTypeDefaultDescription
namestringβ€”Field name β€” must match the key in your values object
labelstringβ€”Rendered as a <label> wired to the field's id
hintstringβ€”Helper text shown below the input (hidden when there is an error)
requiredbooleanfalseAppends a red * to the label
classNamestringβ€”Extra class on the field wrapper
styleCSSPropertiesβ€”Inline style on the field wrapper
childrenReactNodeβ€”The input element(s)

Data attributes on the field wrapper: data-invalid="true" when there's an error, data-touched="true" after the first blur, data-dirty="true" when the value differs from the default. A shake animation plays when a field first becomes invalid on submit.

Form.Submit props #

PropTypeDefaultDescription
childrenReactNode"Submit"Button label
disabledbooleanfalseForce disabled state
classNamestringβ€”Extra class
styleCSSPropertiesβ€”Inline style

The submit button automatically sets disabled and aria-busy="true" while the form is submitting, and shows a spinner next to the label.

ARIA #

register(name) automatically wires aria-invalid, aria-describedby (pointing to the hint and error elements), and aria-required based on the required option. The form root gets aria-busy="true" while submitting. Error elements receive role="alert" so screen readers announce them immediately.

CSS variables #

VariableDefaultDescription
--rfrm-submit-bg#6366f1Submit button background
--rfrm-submit-fg#ffffffSubmit button text colour
--rfrm-submit-bg-hover#4f46e5Submit button hover background
--rfrm-submit-bg-disabled#a5b4fcSubmit button disabled background
--rfrm-submit-radius8pxSubmit button border radius
--rfrm-label-fg#3f3f46Label text colour
--rfrm-label-font-size0.875remLabel font size
--rfrm-label-font-weight500Label font weight
--rfrm-hint-fg#71717aHint text colour
--rfrm-hint-font-size0.75remHint font size
--rfrm-error-fg#dc2626Error text colour
--rfrm-error-border#dc2626Error border colour (for custom inputs)
--rfrm-required-fg#dc2626Required asterisk colour
--rfrm-form-gap1.25remVertical gap between fields
--rfrm-field-gap0.3remGap within a field (label β†’ input β†’ hint)
--rfrm-duration150msAnimation duration

Dark mode tokens are applied automatically when a [data-theme="dark"] ancestor is present. Never uses @media (prefers-color-scheme: dark).

Edit this page on GitHub