npm package: @paragrav/rhf-utils
Description: Integration utilities for react-hook-form.
Author: paragrav.dev
Latest version: 0.63.0

# README

## About

Integration and utility library for [react-hook-form](https://www.react-hook-form.com/) (and [zod](https://www.npmjs.com/package/zod)). Declaratively configure your forms via global and form-level options.

## Features

- TypeScript-first
- global configuration (🔗 [`RhfUtilsClientConfig`](#config) section)
  - RHF (`UseFormProps`) and utilities (`RhfUtilsFormOptions`) options defaults
  - inject your own hooks and UI (`FormChildren`)
  - server error transformation (`onSubmitErrorUnknown`) ✨
  - handle cancel prompt (`useCanFormBeCancelled`) ✨
  - RHF `FormState.errors` logging/throwing (`RhfUtilsClientConfig.fieldErrors`) ✨
- form-level configuration (🔗 [`RhfUtilsZodForm`](#form-with-providers) section)
  - RHF and utilities options overrides
    - extendable utilities options type (`RhfUtilsFormOptions`)
  - throw error (`FormSubmitError`) in submit handler to add errors to RHF context and fail submit
  - handle submit error (`onSubmitError` / `useLastSubmitError`)
  - context injection for children (`RhfUtilsZodForm.Children`), including:
    - formId, formRef, RHF context, utilities options
    - schema-typed Controller component and FormSubmitError class
- form state relay (🔗 [`FormRelayContextProvider`](#form-state-relay) section) ✨
  - access specific form's state from outside that form/provider
  - assign forms into groups and watch collectively
- simpler/flatter `FieldErrors` structure (🔗 [`FlatFieldErrors`](#flatfielderrors) section)
  - find orphans errors (🔗 [orphan errors](#orphan-errors) section) ✨
  - context groups errors into `all`, `fields`, `roots`, and `orphans` (`useFlatFieldErrorsContext`)
- safer `FieldValues` type (🔗 [`SafeFieldValues`](#safefieldvalues) section)
- `zod` support, including input/output types for transformations
- ~2.8kB min+gzip core (excluding [peer dependencies](#peer-dependencies))

## Install

```sh
npm install @paragrav/rhf-utils  # npm
pnpm add @paragrav/rhf-utils     # pnpm
yarn add @paragrav/rhf-utils     # yarn
```

## Quick Start

- 🔗 [Config](#config) (`RhfUtilsClientConfig`): define your global config (optional)
  - 🔗 [Provider](#config-provider) (`RhfUtilsClientConfigProvider`): add to your global stack
- 🔗 [Form With Providers](#form-with-providers) (`RhfUtilsZodFormWithProviders`): use as form component
- 🔗 [Form State Relay](#form-state-relay) (`FormRelayContextProvider`): relay form state

## Config

Create a config (`RhfUtilsClientConfig`) object with desired **global** config and default options. Config applies across all forms, and defaults can be overridden per form.

```tsx
export const rhfUtilsClientConfig: RhfUtilsClientConfig = {
  defaults: {
    // globally-relevant subset of RHF's `UseFormProps` options
    rhf: {
      mode: 'onSubmit',
    },

    // RhfUtilsFormOptions (see further below)
    utils: {
      stopSubmitPropagation: true,
      devTool: import.meta.env.DEV && { placement: 'top-right' },
    },

    // global default native form props
    form: {
      noValidate: true,
      className: 'my-form-class',
    },
  },

  // optional form component to use (defaults to primitive HTML form)
  FormComponent: Form.Root,

  // inject your own hooks and components
  // into all RhfUtilsZodForm instances
  FormChildren: (
    // RhfUtilsFormComponentProps
    {
      formId, //          unique id string
      formRef, //         form element ref
      context, //         rhf UseFormReturn (without proxy `formState`)
      options, //         RhfUtilsFormOptions (with any custom props)
      Controller, //      rhf controller (SafeFieldValues-typed; no schema at this level)
      FormSubmitError, // error class (SafeFieldValues-typed; no schema at this level)
      children, //        RhfUtilsZodForm's Children instance
    },
  ) => {
    useMyGlobalFormHook();

    // form-level control of your own hooks/behaviors via custom option props
    // (see "Extend RhfUtilsFormOptions" section for more info)
    useMyOptionalFormHook({
      enabled: !!options.enableMyOptionalFormHook,
    });

    return (
      <>
        {/* RhfUtilsZodForm.Children "outlet" (see "Component Hierarchy" section) */}
        {children}

        {/* root errors list */}
        <RootErrorsList />
      </>
    );
  },

  // hook that returns callback to determine whether form can be cancelled
  // if `true`, `RhfUtilsZodForm.onCancel` at form-level is called (e.g., parent component hides form)
  // this allows you to handle non-navigational blocking/prompting
  // NOTE: this library is router-agnostic, so navigation blocking should happen elsewhere
  // (i.e., calling your own hook in `FormComponent`)
  useCanFormBeCancelled: () => {
    const { isDirty } = useFormState();

    // return callback to be called at event-time
    return ({ options }) =>
      !options.enableMyPrompter || // my prompter option not enabled
      !isDirty || // or rhf form state not dirty
      confirm('Are you sure you want to cancel?'); // or user confirms
  },

  // non-FormSubmitError thrown in onSubmit
  // use case: handle and transform server error data for frontend
  onSubmitErrorUnknown: (
    error, // unknown
  ) => {
    // return FormSubmitFieldErrors object to be merged to RHF context errors
    if (isMyServerError(error))
      return transformMyServerErrorToFormSubmitFieldErrors(error);
  },

  // RHF FormState.errors output for debugging
  fieldErrors: {
    // callbacks to determine when to output information about field errors
    // provided `FlatFieldErrorsContext` (all, fields, roots, orphans, hasOrphans)
    // (See "Orphan Errors" section below for more info.)
    output: {
      // determine when to console ("debug" or "error")
      console: ({ hasOrphans }) =>
        // facilitate local debugging of form validation
        (import.meta.env.DEV && { type: 'debug' }) ||
        // console error for reporting on prod
        (hasOrphans && { type: 'error' }),

      // callback to determine when to throw an error based on field errors
      throw: ({ hasOrphans }) =>
        // bring attention to orphans locally
        import.meta.env.DEV && hasOrphans,
    },
  },
};
```

## Config Provider

Add the context provider to your global provider stack.

```tsx
<RhfUtilsClientConfigProvider config={rhfUtilsClientConfig}>
  {children}
</RhfUtilsClientConfigProvider>
```

## Form With Providers

Currently, only `zod` schemas are supported.

```tsx
<RhfUtilsZodFormWithProviders
  schema={z.object({
    email: z.string().min(1).email(),
    password: z.string().min(1),
    passwordConfirm: z.string().min(1),
  })}
  defaultValues={{ email: '', password: '', passwordConfirm: '' }}
  getApiData={({ email, password }) => ({ email, password })}
  // cancel handler (passed to `Children` below)
  // execution routed through `RhfUtilsClientConfig.useCanFormBeCancelled`, if provided
  onCancel={handleCancel}
  // SUBMIT HANDLERS
  // invariants
  onBeforeSubmitInvariants={async (
    { input, output, api }, // schema input (form values), output; api data (via getApiData)
    context, //                UseRhfUtilsFormOnSubmitContext (id, ref, rhf context (without proxy formState), utils options, schema-typed FormSubmitError class)
    event, //                  SubmitEvent
  ) => [
    {
      field: 'passwordConfirm', // type safe
      message: 'Passwords must match.',
      valid: data.password === data.passwordConfirm,
    },
  ]}
  onBeforeSubmit={({ input, output, api }, context, event) => {
    if (!isValid(input.email))
      throw new context.FormSubmitError({
        email: { message: 'Email is invalid.' },
      });
  }}
  // submit handler
  onSubmit={({ input, output, api }, context, event) => registerService(api)}
  onSubmitSuccess={(onSubmitResult, { input, output, api }, context, event) =>
    navigate('/app/' + onSubmitResult.id)
  }
  // handle error declaratively (i.e., no throw/catch)
  onSubmitError={({ error, context, event }) => {
    handleError(error);
  }}
  onSubmitFinally={() => {
    setState();
  }}
  // form-specific options/overrides (merged with defaults)
  rhf={{
    mode: 'onBlur',
  }}
  utils={{
    submitOnWatch: { debounce: 250 },
    // custom option props
    // (see "Extend RhfUtilsFormOptions" section)
    enableMyOptionalFormHook: true,
  }}
  // surfaced form element attributes
  id="my-form-id"
  className="my-special-form-class" // merged with global defaults
  // other form element attributes
  form={{ noValidate: true }} // merged with global defaults
  // fields
  Children={({
    // UseRhfUtilsFormChildrenProps
    formId, //          unique id string (auto-gen if not supplied)
    formRef, //         ref
    context, //         rhf UseFormReturn (without proxy `formState`)
    options, //         RhfUtilsFormOptions
    Controller, //      schema-typed rhf controller
    FormSubmitError, // schema-typed error class
    onCancel, //        wrapped cancel handler from above
  }) => (
    <>
      <Controller
        name="email" // schema-typed field name
        render={({ field, formState: { isSubmitting } }) => (
          <Stack>
            <label>
              Email
              <input {...field} disabled={isSubmitting} />
            </label>

            <ErrorMessage path={field.name} />
          </Stack>
        )}
      />

      <button type="button" onClick={onCancel}>
        Cancel
      </button>
      <button type="submit">Login</button>
    </>
  )}
/>
```

### Form Children

`Children` prop takes a component which is rendered as a child of the form (and, if supplied, of `RhfUtilsClientConfig.FormComponent`). It receives `UseRhfUtilsFormChildrenProps` as props, including a type-safe `Controller` component.

If you prefer to define your `Children` component as standalone, you will need to specify any dependencies as `props` (e.g., `onEvent` callback):

```tsx
const Children: RhfUtilsUseZodFormChildrenFC<
  typeof schema,
  { onEvent: () => void } // other props (if required)
> = ({ ... }) => {};

// or

function Children({ ... }: RhfUtilsUseZodFormChildrenProps<
  typeof schema,
  { onEvent: () => void } // other props (if required)
>) {}
```

And pass other props manually, if applicable:

```tsx
<RhfUtilsZodFormWithProviders
  Children={(rhfUtilsZodFormChildrenProps) => (
    <Children
      {...rhfUtilsZodFormChildrenProps}
      props={{ onEvent: handleEvent }}
    />
  )}
/>
```

## Form Component Hierarchy

For each form instance, `RhfUtilsZodForm` renders as the following:

```tsx
<ReactHookForm.FormProvider>             // standard rhf provider
  <RhfUtilsProviders>                    // rhf utils internal providers
    <RhfUtilsClientConfig.FormComponent> // from global config (or native form, if not provided)
      <RhfUtilsZodForm.Children />       // form instance component prop
    </RhfUtilsClientConfig.FormComponent>
  <RhfUtilsProviders>
</ReactHookForm.FormProvider>
```

## De-couple Providers from Form

Sometimes you need to control the nesting of the Form element. For example, to appease HTML rules or expand the scope of the Provider.

```tsx
<aside>
  <RhfUtilsZodFormProviders formId="my-form">
    <main>
      <RhfUtilsZodForm />
    </main>

    <footer>
      <SubmitButton form="my-form">

      <MyFormErrorsViaProviders>
    </footer>
  </RhfUtilsZodFormProviders>
</aside>
```

To access forms' state outside its respective context, including in aggregate across more than one form, see [Form State Relay](#form-state-relay) section.

## `FormSubmitError`

This is an `Error`-based class that you can use to throw a schema-typed error in your submit handler.

(Internally, it uses `FormSubmitFieldErrors` type's structure, which is a flat, simplified version of RHF's `FieldErrors`. It differs from `FlatFieldError` only in that it is narrower. It allows field names from your schema and `root.${string}` keys. And only `type` (optional) and `message` props for error.)

### Example

```tsx
<RhfUtilsZodFormWithProviders
  onBeforeSubmit={async ({ input, output, api }, { FormSubmitError }) => {
    if (!isValid(data))
      throw new FormSubmitError({
        'street.address': { message: 'Street address invalid.' },
      });
  }}
/>
```

Any non-`FormSubmitError` error thrown from your submit handler (e.g., fetch/axios error) can be transformed by `RhfUtilsClientConfig`'s `onSubmitErrorUnknown` callback. This takes an `unknown` error and can return a `FormSubmitFieldErrors` object, which is merged into RHF's form state errors.

Most common use case will be transforming backend errors to frontend shape expected by RHF.

### Last Submit Error

Sometimes, perhaps outside any specific `FormContext`, you need direct access to the actual error object that was thrown, which caused the last submit to fail. The `useLastSubmitError` hook allows you to do just this. (You can probably handle most form-specific cases via `onSubmitErrorUnknown`, which receives error as well.)

## `RhfUtilsFormOptions`

These built-in options can be set globally and/or per form.

```ts
type RhfUtilsFormOptions = {
  /** Stop propagation of submit event. */
  stopSubmitPropagation?: boolean;

  /** Request submit on change. */
  submitOnChange?: { debounce?: number };

  /**
   * Reset form values and state (e.g., isDirty, etc.) after submit -- on success and/or error.
   * - `defaults`: reset current values to defaults (e.g., clear form)
   * - `current`: reset defaults to current values (keep current values)
   */
  resetOnSubmitted?: {
    success?: { values: 'defaults' | 'current' };
    error?: { values: 'defaults' };
  };

  /** Control dev tool options. (Lazy-loaded when truthy value supplied.) */
  devTool?: boolean | Pick<DevtoolUIProps, 'placement' | 'styles'>;
};
```

If you need access to options deeper in your component structure, use `useRhfUtilsContext` to receive `RhfUtilsContext` object, which includes `formId`, `formRef`, `options`, and `lastSubmitStateRef`.

`lastSubmitStateRef` (a ref with possible value of `null | 'submitting' | 'success' | 'error'`) conveys current submit state via ref -- i.e., without needing to wait for next render cycle. If your form navigates away via `onSubmitSuccess`, RHF's `useFormContext` still shows form as `isDirty` and `isSubmitting`, which makes it difficult to distinguish from a user-initiated navigation before form is submitted successfully.

Use `useRhfUtilsContextRequestSubmit` hook to get `requestSubmit` function for current form ref in context. This is useful when you need to trigger form submission programatically.

## Extend `RhfUtilsFormOptions`

Extend `RhfUtilsFormOptions` with custom options, which get passed to `RhfUtilsClientConfig`'s `ChildrenWrapper` and `RhfUtilsZodForm`'s `Children` components via `options` prop. These can take any shape, and allow you to override your own functionality at form-level.

```tsx
import '@paragrav/rhf-utils';

declare module '@paragrav/rhf-utils' {
  export interface Register {
    RhfUtilsFormOptions: {
      /** Custom props for your custom options/hooks/behaviors per form instance. */
      enableMyPrompter?: boolean;
      enableMyOptionalFormHook?: boolean;
      configMyOptionalFormHook?: MyCustomFormHookConfig;
    };
  }
}
```

## <a id="form-state-relay"></a> Form State Relay

Sometimes you need to access one or more forms' state outside its respective context.

`FormRelayContextProvider` is included by default, but form's state is only relayed with opted-in. Configuration is done per form instance via `relay` prop.

```ts
type FormRelayOptions = {
  /**
   * Determines what part of RHF's form state is relayed.
   * Also acts to activate RHF proxy values.
   */
  select: (formState: FormState<SafeFieldValues>) => FormRelayStateSelected;

  /**
   * Arbitrary group name(s) for this form.
   * Think of it like class or category names.
   * (e.g., "parent" or "children")
   */
  groups?: string[];
};
```

There are two main ways to get form state that has been relayed.

### Get form relay by id

You can retrieve a singular form's relay state via `useFormRelayId(formId)`. This will return a `FormRelay` object with that form's selected state (`FormRelayStateSelected`), options (`FormRelayOptions`), and utils (`RhfUtilsContext`).

### Get form relay by group

When retrieving by group, state is merged into a `FormRelayGroup` object (and no options or utils are provided, as these are form-specific).

Use `useFormRelayGroup(callback)` and providing a callback that is is supplied with options (`FormRelayOptions`) and utils (`RhfUtilsContext`) of each form registered to relay, in order for you to determine whether it should be included in this arbitrary "group".

You can also use `FormRelayGrouperContextProvider` if you want to make this group accessible via naive hook.

```tsx
<FormRelayGrouperContextProvider
  predicate={({ options, utils }) =>
    !!options?.groups?.includes("parent") // find specific group
  }
>
  {(state: FormRelayGroup) => ()}
</FormRelayGrouperContextProvider>
```

Alternatively, instead render callback, you can use `useFormRelayGrouper`.

If you want to build some components to be independent of any one specific context -- i.e., RHF's context, `FormRelayGrouperContext`, or `FormRelayContext` -- you can use `useFirstFormStateGroup` to get `FormRelayGroup` from nearest available context. (If RHF's context is found, its singular state is transformed to `FormRelayGroup`.)

## Field Errors

### FlatFieldErrors

`FlatFieldErrors` type is a flattened, simplified version of RHF's `FieldErrors`. Keys represent flattened, dot-notation field paths.

Use `useFlatFieldErrorsContext()` hook, which returns an object with errors grouped by `all`, `fields`, `roots`, `orphans` records, and boolean values for `hasErrors` and `hasOrphans`.

### Output

For the purposes of debugging and/or logging, you can configure when form state errors are outputted (i.e., console and/or thrown) via `RhfUtilsClientConfig.fieldErrors.output` (example at the top).

### Orphan Errors

The concept of an "orphan" error is any errant schema property that could prohibit users from submitting a valid form because its input is missing or non-existent.

Programatically, an "orphan" is any form state error that meets ALL of the following criteria:

- non-field -- i.e., no RHF-supplied `ref` on `FieldError` object
- non-root -- i.e., not `root` or `root.${string}` path
- no corresponding "marker" in DOM (i.e., `RhfUtilsNonFieldErrorMarker`)

This is because:

- field errors (with `ref`s) are assumed to be displayed next to their respective input
- root errors (non-field, without `ref`s) are assumed to always be listed for display
- all other errors must be marked as displayed to distinguish from being an orphan

_See [RhfUtilsNonFieldErrorMarker](#rhfutilsnonfielderrormarker) section below for more information about when and why marker is needed._

#### Using orphan errors

Detected orphans can be accessed via any of the following:

- `RhfUtilsClientConfig.fieldErrors.output` -- e.g., config per environment:
  - _development_: `throw` to facilitate discovery and debugging
  - _production_: `console.error` to facilitate reporting
- `useFlatFieldErrorsContext()` hook
  - returns an object with list of errors grouped by `all`, `fields` (with `ref`), `roots`, `orphans` records, and includes computed booleans `hasErrors` and `hasOrphans`
- boolean value from `useFlatFieldErrorsContextHasOnlyOrphans`

### RhfUtilsNonFieldErrorMarker

_If you don't need orphan detection, you can skip this section. By default, orphan detection still occurs but doesn't otherwise do anything._

To get accurate orphan detection, you must use `RhfUtilsNonFieldErrorMarker` when displaying any non-root non-field errors. (Example further below.)

There is little harm in including it consistently for all individual errors displayed. (DOM traversal to find marker only occurs if error is non-field AND non-root, which is not typical.)

#### When is the marker required? _(example of non-root non-field error)_

A typical example is a field array with a required minimum number of items -- e.g., `items: z.array(...).min(1)`.

When there are zero items, the error is non-field and non-root. You would probably display this error (manually) near the field array, using something like RHF's [`ErrorMessage`](https://www.react-hook-form.com/api/useformstate/errormessage/) component.

In order to NOT detect this as an orphan, it must be explicitly "marked" as displayed using `RhfUtilsNonFieldErrorMarker`.

You should incorporate `RhfUtilsNonFieldErrorMarker` into your own component library's error message display component.

```tsx
<RhfUtilsNonFieldErrorMarker path="items" />
```

_If you include the marker in bulk error lists, it will defeat the purpose of the marker. Only use marker for individual errors. Therefore, if you are ONLY listing a summary of all errors, without individual displays, you will need to use the marker manually._

## Other Features

### TRPC.io Utils _(0.41kB min+gzip)_

#### `trpcClientErrorToFormSubmitFieldErrorsSchemaTransformer`

`zod`-based schema for transforming `TRPCClientErrorLike<AnyRouter>` server errors to `FormSubmitFieldErrors`.

#### `makeOnSubmitTrpcClientErrorHandler`

HOF to make a `onSubmitErrorUnknown` handler for TRPC.io server errors (with fallback callback for non-TRPC errors).

```ts
  onSubmitErrorUnknown: makeOnSubmitTrpcClientErrorHandler((error) => {
    // non-trpc error
  }),
```

## Other

### SafeFieldValues

This library uses `SafeFieldValues` type which uses `unknown` instead of `any`.

### Peer dependencies

- [react](https://www.npmjs.com/package/react)
- [react-dom](https://www.npmjs.com/package/react-dom)
- [react-hook-form](https://www.npmjs.com/package/react-hook-form)
- [@hookform/resolvers](https://www.npmjs.com/package/@hookform/resolvers)
- [@hookform/devtools](https://www.npmjs.com/package/@hookform/devtools) (optional)
- [zod](https://www.npmjs.com/package/zod)
- [flat](https://www.npmjs.com/package/flat) _(805B min+gzip)_