Skip to content

Latest commit

 

History

History

README.md

@kintools/form-react

JSR @kintools/form-react License: MIT 100% type-safe

React bindings for @kintools/form-core: useForm to create a form, Watch (or the underlying useWatch hook) to subscribe a component to an already-resolved FieldApi/FormApi, resolved via parent.field(name, options) directly, no separate hook for that. @kintools/form-react depends on and re-exports everything from @kintools/form-core, so no need to install it separately.

Install

npm add @kintools/form-react
pnpm add @kintools/form-react
deno add jsr:@kintools/form-react

Quick start

import { useForm, Watch } from "@kintools/form-react";
import { required } from "@kintools/form-validators";

function LoginForm() {
  const form = useForm({
    initialValue: { email: "", password: "" },
    onSubmit: async (form) => await login(form.value),
    onSubmitError: () => toast.error("Failed to log in"),
  });

  return (
    <form onSubmit={form.handleSubmit}>
      <Watch
        api={form.field("email", { validators: required("Email is required") })}
      >
        {(field) => (
          <>
            <input
              value={field.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.invalid && field.touched && <span>{field.error}</span>}
          </>
        )}
      </Watch>

      <Watch api={form} select={(f) => f.submitting}>
        {(_form, submitting) => (
          <button type="submit" disabled={submitting}>Log in</button>
        )}
      </Watch>
    </form>
  );
}

useForm

Creates a FormApi once and calls updateOptions on it every render, so onSubmit/onSubmitInvalid/onSubmitError (and validators/dependents) stay in sync with the latest render's closures instead of going stale. Doesn't itself subscribe the calling component; pass the returned instance to Watch for that.

Resolving a field

parent.field(name, options) (see @kintools/form-core) gets (creating on first call) the FieldApi registered under name on parent, for a leaf value, a nested object/array, or an array item alike, the same accessor either way. It's safe to call inline in JSX on every render: on an already-registered field, options is applied via updateOptions the same way every time, so re-calling it doesn't re-create anything. For an array, the field's own value is the array, so array methods are called on it with "".

There's no separate hook for this: field() is a plain method, and inferring its name argument from a string literal works reliably without one.

Watch

A render-prop component that subscribes to any already-resolved FieldApi/FormApi, without writing a custom component around useWatch yourself:

function EmailInput({ parent }: { parent: FieldApi<{ email: string }> }) {
  return (
    <Watch
      api={parent.field("email", {
        validators: [(f) => (f.value ? null : "Email is required")],
      })}
    >
      {(field) => (
        <input
          value={field.value}
          onBlur={field.handleBlur}
          onChange={(e) => field.handleChange(e.target.value)}
        />
      )}
    </Watch>
  );
}

children always receives api as its first argument; pass select to additionally narrow the subscription down to a selected value, passed as the second argument, instead of re-rendering on every change:

<Watch api={itemsField} select={(f) => f.value.length}>
  {(_field, count) => <span>{count} items</span>}
</Watch>;

Works the same way for a nested object or array: resolve the parent field first, then call field/Watch again on it:

<Watch api={form.field("address")}>
  {(address) => (
    <Watch api={address.field("city")}>{(f) => <input /* ... */ />}</Watch>
  )}
</Watch>;

For an array, a field's own value is the array, so array methods are called on it with "", which stays generic over where in the tree it's mounted:

function ArrayField<Item>(
  { api, newItem }: { api: FieldApi<Item[]>; newItem: () => Item },
) {
  return <button onClick={() => api.pushItem("", newItem())}>Add</button>;
}

For a field that appears in multiple places, define a reusable component around Watch (or useWatch directly) instead (e.g. TextField, AddressField) rather than repeating a <Watch> render prop everywhere; see Form Composition.

useMultistep

Orchestrates a wizard's current-step state on top of one step per named FieldApi: validating the current step, waiting for it to settle, and gating the advance.

const wizard = useMultistep(form, ["shipping", "payment", null]);

wizard.stepName; // "shipping" | "payment" | null
wizard.next(); // validates the current step, then advances
wizard.back();
wizard.jump("payment");

See Multistep Forms for branching, persisting progress mid-wizard, and onStepChanged.

Learn more