Skip to content

Type-safe reusable sub-forms #31

@happenslol

Description

@happenslol

I want to create type-safe, reusable components for complex sub-forms.

Consider the following example:

type FormValues = {
  name: string
  address: Address
}

const MyForm = () => {
  const form = useForm()
  const handleSubmit = (data: FormValues) => console.log(data)

  return (
    <form onSubmit={form.handleSubmit(handleSubmit)}>
      <input {...form.register("name")} />
      <AddressInput form={form} name="address" />
    </form>
  )
}

type Address = {
  street: string
  city: string
  country: CountryCode
}

type AddressInputProps = {
  form: UseFormReturn
  name: string
}

const AddressInput = (props: AddressInputProps) => (
  <div>
    <input {...form.register(`${props.name}.street`)} />
    <input {...form.register(`${props.name}.city`)} />
    <CountrySelect control={props.form.control} name={`${props.name}.country`} />
  </div>
)

Now, I want to use AddressInput in different forms. To be able to use both register and control, it makes most sense to just pass the entire form object to the input. I can get type safety for the AddressInput.name prop by using FieldPathByValue:

type AddressInputProps<
  TFieldValues extends FieldValues,
  TPath extends FieldPathByValue<TFieldValues, Address>,
> = {
  name: TPath
  form: UseFormReturn<TFieldValues>
}

// "name" is now type checked and has autocomplete
<AddressInput form={form} name="address" />

However, since Typescript does not support narrowing generic string literals inside other literals, the following now throws an error:

// Inside AddressInput
<input {...props.form.register(`${props.name}.street`)} />

// Argument of type '`${TPath}.street`' is not assignable to parameter of type 'Path<TFieldValues>'.
//  Type '`${string}.street`' is not assignable to type 'Path<TFieldValues>'. [2345]

Even though props.name is of type TPath and the type `${TPath}.street` should satisfy FieldPath<TFieldValues>, it gets broadened to `${string}.street`. which does not.

It's also arguably unergonomic having to prefix every form field name with ${props.name} inside AddressInput, and the input theoretically has access to the entire outer form.

Possible Solutions

I can see multiple ways to solve this problem. Essentially, there would have to be a way to create a "sub-form" that is scoped to a specific field.
This could either be a function on UseFormReturn:

const { form: addressForm, name: addressName } = form.registerSubForm("address")
<AddressInput form={addressForm} name={addressName} />

// Or the shorthand:
<AddressInput {...form.registerSubForm("address")} />

Or a new useSubForm hook: that can be called from within AddressInput:

const AddressInput = (props: AddressInputProps) => {
  // The arguments here can be constrainted so that they only accept
  // FieldPathByValue<TFieldValues, Address>
  const form = useSubForm(props.form, props.name)

  // All fields within form are now scoped to the "address" field, including
  // watch, control, etc. - basically every path is prefixed with "address.".

  return (
    <div>
      <input {...form.register("street")} />
      <input {...form.register("city")} />
      <CountrySelect control={form.control} name="country" />
    </div>
  )
}

Alternatives

All alternatives I've considered make tradeoffs in type safety or performance:

  1. It's possible to just pass a control to the AddressInput, but every update would rerender the entire component, which is fine in this case - but not when there are multiple nested fields even within a sub-component. It's also not possible to use register in this case.
  2. It's possible to actually use sub-forms, but then all state (validation, dirty, touched, handlers) needs to be passed up, which is not ideal and not always possible.
  3. The approach in the initial example is a possibility, but does not offer type safety and is unergonomic because every field needs to be prefixed.

I would love to use react-hook-form for a large project I'm working on, but we have lots of complex components with nested fields that are used in multiple forms.

Having an API like this would go a long way when it comes to composing forms from smaller, isolated parts, and even allowing libraries containing complex input components to be published.

If this is a feature you would consider, I'd be happy to take care of the implementation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions