Integration and utility library for react-hook-form (and zod). Declaratively configure your forms via global and form-level options.
- TypeScript-first
- global configuration (
RhfUtilsClientConfig
)- RHF (
UseFormProps
) and utilities (RhfUtilsFormOptions
) options defaults - inject your own hooks and UI (
FormChildrenWrapper
) - server error transformation (
onSubmitErrorUnknown
) - RHF
FormState.errors
logging/throwing (RhfUtilsClientConfig.fieldErrors
)
- RHF (
- form-level configuration (
RhfUtilsZodForm
)- RHF and utilities options overrides
- extendable utilities options type (
RhfUtilsFormOptions
)
- extendable utilities options type (
- throw error (
FormSubmitError
) in submit handler to add errors to RHF context and fail submit - handle submit error (
onSubmitError
) - dependency injection for children (
RhfUtilsZodForm.Children
), including:- formId, formRef, RHF context, utilities options
- schema-typed Controller component and FormSubmitError class
- RHF and utilities options overrides
- simpler/flatter
FieldErrors
structure (FlatFieldErrors
)- context groups errors into
all
,fields
,roots
, andorphans
(useFlatFieldErrorsContext
)
- context groups errors into
- safer
FieldValues
type (SafeFieldValues
) -
zod
support, including input/output types for transformations - ~3.2kB min+gzip core (excluding peer dependencies)
npm install @paragrav/rhf-utils # npm
pnpm install @paragrav/rhf-utils # pnpm
yarn add @paragrav/rhf-utils # yarn
- 🔗 Config (
RhfUtilsClientConfig
): define your global config (optional) - 🔗 Provider (
RhfUtilsClientForZodContextProvider
): add to your global stack - 🔗 Form (
RhfUtilsZodForm
): use as form component
Create a config (RhfUtilsClientConfig
) object with desired global config and default options. Config applies across all forms, and defaults can be overridden per form.
export const rhfUtilsClientConfig: RhfUtilsClientConfig = {
// overridable at individual form level
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 (defaults to primitive HTML form)
FormComponent: Form.Root,
// optional wrapper component to inject your own hooks and components
// around all RhfUtilsZodForm instances
FormChildrenWrapper: (
// UseRhfUtilsFormChildrenProps
{
formId, // unique id string
formRef, // 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
useCanFormBeCancelled: () => {
const { isDirty } = useFormState();
const asyncPrompt = useAsyncPrompter();
// return callback to be called at event-time
// with same props as `RhfUtilsClientConfig.FormChildrenWrapper`
return async (props: RhfUtilsFormChildrenWrapperProps) =>
!isDirty || // rhf form state not dirty
(await asyncPrompt('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,
},
},
};
Add the context provider to your global provider stack.
<RhfUtilsClientForZodContextProvider config={rhfUtilsClientConfig}>
{children}
</RhfUtilsClientForZodContextProvider>
Currently, only zod
is supported.
<RhfUtilsZodForm
schema={loginFormSchema}
defaultValues={{
email: '',
}}
// cancel handler (passed to `Children` below)
// execution routed through `RhfUtilsClientConfig.useCanFormBeCancelled`, if provided
onCancel={handleCancel}
// submit handler
onSubmit={async (
data, // schema output
context, // UseRhfUtilsFormOnSubmitContext (id, ref, rhf context (without proxy formState), utils options, schema-typed FormSubmitError class)
event, // SubmitEvent
) => {
await loginService(data);
handleSuccess();
}}
// handle error declaratively (i.e., no throw/catch)
onSubmitError={({
error, // unknown
context, // UseRhfUtilsFormOnSubmitErrorContext (id, ref, rhf context (without proxy formState), utils options, `FormSubmitFieldErrors` from `onSubmitErrorUnknown`)
event, // SubmitEvent
}) => {
handleError(error);
}}
// fields
Children={({
// UseRhfUtilsFormChildrenProps
formId, // unique id string
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 } }) => (
<label>
Email
<input {...field} disabled={isSubmitting} />
<ErrorMessage path={field.name} />
</label>
)}
/>
<button type="button" onClick={onCancel}>
Cancel
</button>
<button type="submit">Login</button>
</>
)}
// form-specific options/overrides (merged with defaults)
rhf={{
mode: 'onBlur',
}}
utils={{
submitOnChange: true,
// custom option props
// (see "Extend RhfUtilsFormOptions" section)
enableMyOptionalFormHook: true,
}}
form={{
// class names are merged together with global defaults
className: 'my-special-form-class',
}}
/>
Children
prop takes a component which is rendered as a child of the form (and, if supplied, of RhfUtilsClientConfig.FormChildrenWrapper
). 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):
const Children: RhfUtilsUseFormChildrenZodFC<
typeof schema,
'cancelable', // expects cancel handler
{ onEvent: () => void } // other props (if required)
> = ({ ... }) => {};
// or
function Children({ ... }: RhfUtilsUseFormChildrenZodProps<
typeof schema,
'cancelable', // expects cancel handler
{ onEvent: () => void } // other props (if required)
>) {}
And pass other props manually, if applicable:
<RhfUtilsZodForm
Children={(rhfUtilsZodFormChildrenProps) => (
<Children
{...rhfUtilsZodFormChildrenProps}
props={{ onEvent: handleEvent }}
/>
)}
/>
RhfUtilsZodForm
renders as:
<ReactHookForm.FormProvider> // standard rhf provider
<RhfUtilsProviders> // rhf utils internal providers
<RhfUtilsClientConfig.FormComponent> // from global config (or default)
<RhfUtilsClientConfig.FormChildrenWrapper> // from global config (if provided)
<RhfUtilsZodForm.Children /> // form instance component prop
</RhfUtilsClientConfig.FormChildrenWrapper>
</RhfUtilsClientConfig.FormComponent>
<RhfUtilsProviders>
</ReactHookForm.FormProvider>
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.)
<RhfUtilsZodForm
onSubmit={async (data, { FormSubmitError }) => {
if (isProblem(data))
throw new FormSubmitError({
'street.address': { message: 'Street address invalid.' },
});
await post('/backend');
}}
/>
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.
These built-in options can be set globally and/or per form.
type RhfUtilsFormOptions = {
/** Stop propagation of submit event. */
stopSubmitPropagation?: boolean;
/**
* Request submit via listener on form change.
* - `true`: no debounce
* - number: milliseconds to debounce
*/
submitOnChange?: boolean | 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
, and options
settings.
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
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.
import '@paragrav/rhf-utils';
declare module '@paragrav/rhf-utils' {
export interface Register {
RhfUtilsFormOptions: {
/** Custom props for your custom options/hooks/behaviors. */
enableMyOptionalFormHook?: boolean;
configMyOptionalFormHook?: MyCustomFormHookConfig;
};
}
}
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
.
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).
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
onFieldError
object - non-root -- i.e., not
root
orroot.${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 section below for more information about when and why marker is needed.
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
-
development:
-
useFlatFieldErrorsContext()
hook- returns an object with list of errors grouped by
all
,fields
(withref
),roots
,orphans
records, and includes computed booleanshasErrors
andhasOrphans
- returns an object with list of errors grouped by
- boolean value from
useFlatFieldErrorsContextHasOnlyOrphans
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.)
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
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.
<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.
Sometimes you need to group multiple forms together.
Use case: disable parent form when any child is "busy"; disable all children when parent is "busy".
Use FormGroupContextProvider
to wrap your children and parent forms.
<FormGroupContextProvider>
<ChildForm1 />
<ChildForm2 />
<ParentForm />
</FormGroupContextProvider>
In your parent form, use the hook useFormGroupParentTracker
, which returns boolean
value indicating whether any child forms is busy.
In children forms, there are two options. You can choose to consider form "busy" when:
-
isSubmitting
istrue
by usinguseFormGroupChildIsSubmittingTracker
hook - it is mounted by using
useFormGroupChildIsMountedTracker
hook
Both hooks return boolean
value indicating whether parent form is busy.
zod
-based schema for transforming TRPCClientErrorLike<AnyRouter>
server errors to FormSubmitFieldErrors
.
HOF to make a onSubmitErrorUnknown
handler for TRPC.io server errors (with fallback callback for non-TRPC errors).
onSubmitErrorUnknown: makeOnSubmitTrpcClientErrorHandler((error) => {
// non-trpc error
}),
This library uses SafeFieldValues
type which uses unknown
instead of any
.
- react
- react-dom
- react-hook-form
- @hookform/resolvers
- @hookform/devtools (optional)
- zod
- flat (805B min+gzip)