Create react hook form with optional nested values with a prefix

Viewed 988

I have many forms defined with react-hook-form. Sometimes I use them independently and some other times I used them combined. Imagine I have a Task form and a Note form. Sometimes I have one form to submit a Task and sometimes I have one form to submit a Note and Task. I am trying to manage name collisions between the various forms by prefixing the input elements with a string.

I want to define a Task form and use it as <TaskForm/> when used independently and <TaskForm prefix="task"/> when combining with other forms. So far I have something working, but typescript is giving a very hard time.

Assuming a simple data type

type TaskFormData = {
    title: string;
}

I would like to use a simple Form, in which there is no name clashes:

export const Form = () => {
    const methods = useForm<TaskFormData>({
        defaultValues: { title: 'Some input value' }
    });
    const { handleSubmit } = methods;
    const onSubmit = (data: TaskFormData) => {
        alert(JSON.stringify(data));
        // should be { "title": "input value" }
    };
    return (
        <FormProvider {...methods}>
            <form onSubmit={handleSubmit(onSubmit)}>
                <TaskForm /> <!-- with a field description -->
            </form>
        </FormProvider>
    );
}

Or a prefixed Form, in which I would use potentially various forms with field names clashing.

export const PrefixedForm = () => {
    const methods = useForm<{ task: NestedValue<TaskFormData> }>({
        defaultValues: { task: { title: 'Some input value' } }
    });
    const { handleSubmit } = methods;
    const onSubmit = (data: { task: TaskFormData }) => {
        alert(JSON.stringify(data));
        // should be { "task": { "title": "input value" } }
    };
    return (
        <FormProvider {...methods}>
            <form onSubmit={handleSubmit(onSubmit)}>
                <NoteForm prefix="note" /> <!-- with a field note.description -->
                <TaskForm prefix="task" /> <!-- with a field task.description -->
            </form>
        </FormProvider>
    );
};

So far this is the working example of a TaskForm

export const TaskForm = ({ prefix }: { prefix?: string }) => {
    const { register } = useFormContext<any>();
    return (
        <input {...register(prefix ? `${prefix}.title` : 'title')} />
    );
};

The useFormContext<any> is really giving me a hard time here. I would like to keep the type information inside each of the forms. But can't seem to be able to get to the correct typescript.

After much research I really thought that this would work:

export type PrefixedTaskFormData<P extends string> = {
    [K in keyof TaskFormData as `${P}.K`]: TaskFormData[K]
}

export const TaskForm = <P extends string>({ prefix }: { prefix?: P }) => {
    const { register } = useFormContext<TaskFormData | PrefixedTaskFormData<P>>();
    return (
        <input {...register(prefix ? `${prefix}.title` : 'title')} />
    );
};

Also tried

export type PrefixedTaskFormData<P extends string> = {
    [K in P]: NestedValue<TaskFormData>
}

export const TaskForm = <P extends string>({ prefix }: { prefix?: P }) => {
    const { register } = useFormContext<TaskFormData | PrefixedTaskFormData<P>>();
    return (
        <input {...register(prefix ? `${prefix}.title` : 'title')} />
    );
};

And nothing.

Any ideas?

1 Answers

This is my current solution, using a higher-order component:

https://codesandbox.io/s/react-hook-form-watch-v7-ts-forked-b84nri?file=/src/index.tsx

I've pasted some of the code below for easier reference. See especially the withForm HOC.

import { FieldError, Path, UseFormReturn } from 'react-hook-form';
import { get } from 'lodash';
import React, { ComponentType } from 'react';

/**
 * Props for components wrapped with {@link withForm} should extend this interface.
 *
 * Do not pass in a generic, since you don't know what the parent form value will be.
 *
 * @example
 * export interface MyComponentProps extends FormProps {
 *   myCustomProps: string;
 * }
 */
// Deliberately using `any` since we don't know what the parent form will look like.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface NestedFormProps<ParentComponentFormValue = any> {
    form: UseFormReturn<ParentComponentFormValue>;
    formPath?: Path<ParentComponentFormValue>;
    disabled?: boolean;
}

/**
 * A nested form component doesn't know what the parent form looks like, so it
 * needs to use the `formPath` passed down to it. It's easy to forget to use this,
 * however, so we nest the form value of the component in a key called `unknownPath`.
 * This makes it obvious that there's an issue when you try to pass in a literal
 * string without prepending the provided formPath, since TS will provide autocomplete
 * options prepended with `unknownPath`.
 *
 * To get the proper path, use the `getFormPath` function that's given to you by
 * {@link withForm}. You can also use {@link createGetFormPath}, but the {@link withForm}
 * is preferred.
 */
export interface NestedFormValue<Value> {
    unknownPath: Value;
}

/**
 * A higher-order component, designed for nested form components.
 *
 * [CodeSandbox Example](https://codesandbox.io/s/react-hook-form-watch-v7-ts-forked-b84nri?file=/src/index.tsx)
 *
 * @example
 * ```tsx
 * // App.tsx
 * import { PersonForm, PersonFormValue } from "./PersonForm";
 *
 * function App() {
 *   const form = useForm<PersonFormValue>();
 *
 *   return (
 *     <>
 *       <PersonForm form={form} msg="Nested Forms Example" />
 *     </>
 *   );
 * }
 *
 * // PersonForm.tsx
 * export interface PersonFormProps extends NestedFormProps {
 *   msg: string;
 * }
 *
 * export interface PersonFormValue {
 *   name: NameFormValue;
 * }
 *
 * export const PersonForm = withForm<PersonFormValue, PersonFormProps>(
 * ({ form, getFormPath, msg }) => {
 *   return (
 *     <>
 *       <h3>{msg}</h3>
 *       <NameForm form={form} formPath={getFormPath("name")} />
 *     </>
 *   );
 * });
 *
 * // NameForm.tsx
 * export const NameForm = withForm<NameFormValue, NameFormProps>(
 * ({ form, getFormPath }) => {
 *   return (
 *     <>
 *       <label>First</label>
 *       <input {...form.register(getFormPath("first"))} />
 *       <label>Last</label>
 *       <input {...form.register(getFormPath("last"))} />
 *     </>
 *   );
 * });
 * ```
 */
export function withForm<Value, Props extends NestedFormProps>(
    WrappedComponent: ComponentType<NestedFormInternalProps<Value, Props>>
) {
    function Component<ParentValue>({ form, formPath, ...props }: Omit<Props, keyof NestedFormProps> & NestedFormProps<ParentValue>) {
        return <WrappedComponent {...getNestedFormInternalProps<Value>(form, formPath)} {...props} />;
    }

    // for React Dev Tools
    Component.displayName = `withTheme(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;

    return Component;
}

/** The props that are passed to a component wrapped with {@link withForm} */
export type NestedFormInternalProps<Value, Props> = ReturnType<NestedFormInternalPropsHelper<Value>['get']> &
    Omit<Props, 'form' | 'formPath'>;

/** Used internally by {@link withForm} */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getNestedFormInternalProps<Value>(form: UseFormReturn<Value>, formPath?: Path<Value>) {
    function getFormPath(): 'unknownPath';
    function getFormPath<FormPath extends Path<Value>>(path: FormPath): `unknownPath.${FormPath}`;
    function getFormPath<FormPath extends Path<Value>>(path?: FormPath) {
        return path ? (concatPaths(formPath, path) as `unknownPath.${FormPath}`) : (formPath as 'unknownPath');
    }

    function getErrors(): typeof form.formState.errors;
    function getErrors(path: Path<Value>): FieldError;
    function getErrors(path?: Path<Value>) {
        return path ? (get(form.formState.errors, getFormPath(path)) as FieldError) : form.formState.errors;
    }

    return {
        form: (form as unknown) as UseFormReturn<NestedFormValue<Value>>,
        getFormPath,
        getErrors,
    };
}

/**
 * Used to join parent form paths with child form paths.
 *
 * @example
 * ```ts
 * concatPaths('a.', '.b.', '3, 'd.'); // returns 'a.b.3.d'
 * ```
 */
export function concatPaths(...paths: (string | number | undefined | null)[]): string {
    return paths
        .join('.') // add . between each path
        .replace(/\.{2,}/g, '.') // replace .. with .
        .replace(/^\./, '') // remove leading .
        .replace(/\.$/, ''); // remove trailing .
}

/**
 * @private Only exists to allow passing a generic to `ReturnType`
 *
 * Can be removed once instantiation expressions are available
 * https://devblogs.microsoft.com/typescript/announcing-typescript-4-7-beta/#instantiation-expressions
 */
class NestedFormInternalPropsHelper<Value> {
    // has no explicit return type so we can infer it
    get(form: UseFormReturn<Value>, formPath?: Path<Value>) {
        return getNestedFormInternalProps(form, formPath);
    }
}

Not pretty, but it works.

Looks like there is some movement on including a solution in the library itself.

Related