Docs
Forms
Forms
Custom hooks and components for forms using react-hook-form.
Loading...
Installation
Create lib/form/utils.ts
import type { BaseSyntheticEvent } from "react";
import { useId, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import type {
ControllerRenderProps,
DefaultValues,
FieldArray,
FieldArrayPath,
FieldArrayWithId,
FieldErrors,
FieldPath,
FieldPathValue,
FieldValues,
SubmitErrorHandler,
SubmitHandler,
UseFormReturn as UseDefaultFormReturn,
UseFieldArrayProps,
} from "react-hook-form";
import {
useFieldArray as useReactFieldArray,
useForm as useReactHookForm,
} from "react-hook-form";
import type { z } from "zod";
/* -------------------------------------------------------------------------- */
/* Transformers */
/* -------------------------------------------------------------------------- */
export const transformField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
field: ControllerRenderProps<TFieldValues, TName>,
asDefaultValue = false,
) => {
const { value, ...fieldRest } = field;
return {
...(asDefaultValue ? { defaultValue: value } : { value }),
...fieldRest,
};
};
export const transformToCheckboxFieldField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
field: ControllerRenderProps<TFieldValues, TName>,
asDefaultValue = false,
) => {
const { onChange, value, name, ref, onBlur } = field;
return {
...(asDefaultValue ? { defaultSelected: value } : { isSelected: value }),
...{ onChange, name, ref, onBlur },
value: name,
};
};
export const transformToSliderFieldField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
field: ControllerRenderProps<TFieldValues, TName>,
asDefaultValue = false,
) => {
const { onChange, value, ...props } = field;
return {
...props,
...(asDefaultValue ? { defaultValue: value } : { value }),
onValueChange: (value: number[]) => {
onChange(value);
},
};
};
/* -------------------------------------------------------------------------- */
/* Hooks */
/* -------------------------------------------------------------------------- */
export function useCustomAriaIds({
errorOrErrorMessage,
description,
}: {
errorOrErrorMessage?: string;
description?: string;
}) {
const id = useId();
const descriptionId = useId();
const errorId = useId();
const describedByArray = [
description && descriptionId,
errorOrErrorMessage && errorId,
];
return {
id,
describedBy:
describedByArray.length > 0 ? describedByArray.join(" ") : undefined,
descriptionId,
errorId,
error: errorOrErrorMessage,
};
}
type Key = string | number;
export type FieldArrayWithIdAndKey<
TFieldValues extends FieldValues = FieldValues,
TFieldArrayName extends
FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>,
TKeyName extends string = "key",
> = FieldArrayWithId<TFieldValues, TFieldArrayName, "id"> &
Record<TKeyName, Key>;
export const useFieldArray = <
TFieldValues extends FieldValues = FieldValues,
TFieldArrayName extends
FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>,
TKeyName extends string = "id",
>(
props: UseFieldArrayProps<TFieldValues, TFieldArrayName, TKeyName>,
) => {
const fieldArray = useReactFieldArray(props);
const fields = useMemo(() => {
if (fieldArray.fields.length === 0)
return [] as FieldArrayWithIdAndKey<TFieldValues, TFieldArrayName>[];
const item = fieldArray.fields[0]!;
if (!("key" in item)) {
return fieldArray.fields.map((field) => ({
...field,
// @ts-expect-error id is added from react-hook-form
key: field.id as string,
})) as FieldArrayWithIdAndKey<TFieldValues, TFieldArrayName>[];
}
return fieldArray.fields as FieldArrayWithIdAndKey<
TFieldValues,
TFieldArrayName
>[];
}, [fieldArray.fields]);
function insertBefore(
key: Key,
value:
| FieldArray<TFieldValues, TFieldArrayName>
| FieldArray<TFieldValues, TFieldArrayName>[],
) {
let i = 0;
for (const item of fields) {
if (item.key == key) {
fieldArray.insert(i, value);
break;
}
i++;
}
}
function removeKeys(...keys: Key[]) {
let i = 0;
for (const item of fields) {
if (keys.includes(item.key)) {
fieldArray.remove(i);
}
i++;
}
}
function get(key: Key) {
return fields.find((item) => item.key == key);
}
function getDoubtless(key: Key) {
const value = get(key);
if (!value) {
throw new Error("Found no field with this key.");
}
return value;
}
return {
...fieldArray,
fields,
insertBefore,
removeKeys,
getDoubtless,
get,
};
};
export interface FormOptions<TSchema extends z.ZodType> {
id?: string;
onSubmit?: (data: z.infer<TSchema>) => void | Promise<void>;
onError?: (errors: FieldErrors<z.infer<TSchema>>) => Promise<void> | void;
defaultValues?: DefaultValues<z.infer<TSchema>>;
reValidateMode?: "onSubmit" | "onBlur" | "onChange";
isSubmitting?: boolean;
}
export type UseFormReturn<
TFieldValues extends FieldValues = any,
TContext = any,
TTransformedValues extends FieldValues | undefined = undefined,
> = Omit<
UseDefaultFormReturn<TFieldValues, TContext, TTransformedValues>,
"handleSubmit"
> & {
id: string;
formProps: {
id: string;
onSubmit: (e?: BaseSyntheticEvent | undefined) => Promise<void>;
};
handleSubmit: (e?: unknown) => void;
safeWatch: <TFieldName extends FieldPath<TFieldValues>>(
name: TFieldName,
defaultValue?: FieldPathValue<TFieldValues, TFieldName>,
) => FieldPathValue<TFieldValues, TFieldName> | undefined;
schema: TFieldValues;
isSubmitting?: boolean;
};
export const useForm = <TSchema extends z.ZodType>(
schema: TSchema,
options?: FormOptions<TSchema>,
): UseFormReturn<z.infer<TSchema>> => {
type FormSchema = z.infer<TSchema>;
const id = useId();
const form = useReactHookForm<FormSchema>({
defaultValues: options?.defaultValues,
reValidateMode: options?.reValidateMode ?? "onChange",
resolver: zodResolver(schema),
});
// async handler for onValid callback
const onValid: SubmitHandler<FormSchema> = async (data, e) => {
e?.preventDefault();
e?.stopPropagation();
if (options?.onSubmit) await options.onSubmit(data);
};
// async handler for onError callback
const onError: SubmitErrorHandler<FormSchema> = async (errors, e) => {
e?.preventDefault();
e?.stopPropagation();
if (options?.onError) await options.onError(errors);
};
// return the function form.watch with the same types but with an added undefined return
const safeWatch = <TFieldName extends FieldPath<FormSchema>>(
name: TFieldName,
defaultValue?: FieldPathValue<FormSchema, TFieldName>,
): FieldPathValue<FormSchema, TFieldName> | undefined => {
const value = form.watch(name, defaultValue);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return typeof value === "undefined" ? undefined : value;
};
function handleSubmit(_e?: unknown) {
void form.handleSubmit(onValid, onError)();
}
return {
...form,
id: options?.id ?? id,
formProps: {
id: options?.id ?? id,
onSubmit: form.handleSubmit(onValid, onError),
},
//form,
handleSubmit,
safeWatch,
schema: schema,
isSubmitting: options?.isSubmitting,
};
};
Create lib/form/context.ts
import React from "react";
import type { FieldPath, FieldValues } from "react-hook-form";
import type { UseFormReturn } from "@/lib/form/utils";
/* -------------------------------------------------------------------------- */
/* Form */
/* -------------------------------------------------------------------------- */
export type FormContextValue<
TFieldValues extends FieldValues = any,
TContext = any,
TTransformedValues extends FieldValues | undefined = undefined,
> = UseFormReturn<TFieldValues, TContext, TTransformedValues>;
export const FormContext = React.createContext<FormContextValue | null>(null);
export const useFormContext = <
TFieldValues extends FieldValues,
TContext = any,
TTransformedValues extends FieldValues | undefined = undefined,
>() => {
const context = React.useContext(FormContext);
if (!context) {
throw new Error("useForm should be used within <Form>");
}
return context as unknown as FormContextValue<
TFieldValues,
TContext,
TTransformedValues
>;
};
/* -------------------------------------------------------------------------- */
/* FormField */
/* -------------------------------------------------------------------------- */
export interface FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> {
name: TName;
}
export const FormFieldContext =
React.createContext<FormFieldContextValue | null>(null);
export const useFormFieldContext = () => {
const formContext = React.useContext(FormContext);
const fieldContext = React.useContext(FormFieldContext);
if (!formContext || !fieldContext) {
return null;
}
const fieldState = formContext.getFieldState(
fieldContext.name,
formContext.formState,
);
return {
...fieldState,
};
};
Create lib/form/index.tsx
import React from "react";
import { FieldError } from "react-aria-components";
import type { ControllerProps, FieldPath, FieldValues } from "react-hook-form";
import { Controller } from "react-hook-form";
import { formVariants } from "@/components/ui/form";
import {
FormContext,
FormFieldContext,
useFormFieldContext,
} from "@/lib/form/context";
import type { UseFormReturn } from "@/lib/form/utils";
import { autoRef, cn, withRenderProps } from "@/lib/utils";
/* ---------------------------------- Root ---------------------------------- */
export interface FormProps {
form: UseFormReturn;
className?: string;
children?: React.ReactNode;
as?: "form" | "div";
}
export const Form = ({ form, className, children, as }: FormProps) => {
const FormWrapper = typeof as === "undefined" ? "form" : as;
const { id, onSubmit } = form.formProps;
return (
<FormContext.Provider value={form}>
<FormWrapper
id={id}
onSubmit={FormWrapper === "form" ? onSubmit : undefined}
className={className}
>
{children}
</FormWrapper>
</FormContext.Provider>
);
};
/* ---------------------------------- Field --------------------------------- */
export type FormFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = ControllerProps<TFieldValues, TName> & {
nullableDefaultValue?: TFieldValues[TName] | null;
};
export const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
nullableDefaultValue,
defaultValue,
...props
}: FormFieldProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller
defaultValue={
nullableDefaultValue !== undefined
? (nullableDefaultValue as TFieldValues[TName])
: defaultValue
}
{...props}
/>
</FormFieldContext.Provider>
);
};
/* -------------------------------- FormError ------------------------------- */
export type FormErrorProps = React.ComponentPropsWithRef<typeof FieldError>;
export const FormError = autoRef(
({ children, className, ...props }: FormErrorProps) => {
const formField = useFormFieldContext();
if (!children && !formField?.error?.message) return null;
return (
<FieldError className={cn(formVariants.error(), className)} {...props}>
{(values) => (
<>
{formField?.error?.message ??
formField?.error ??
withRenderProps(children)(values)}
</>
)}
</FieldError>
);
},
);