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>
    );
  },
);