Skip to content
This repository has been archived by the owner on Jun 15, 2022. It is now read-only.

Using TypedController for nested input and useFormContext #14

Closed
Amiryy opened this issue Aug 4, 2020 · 5 comments
Closed

Using TypedController for nested input and useFormContext #14

Amiryy opened this issue Aug 4, 2020 · 5 comments
Assignees
Labels
question Further information is requested

Comments

@Amiryy
Copy link

Amiryy commented Aug 4, 2020

Hello,
I'm trying to use a TypedController inside my nested input components combined with useFormContext.
I want to maintain the type-safety of name and defaultValue props, using the example from the README of this repository as a basis.

Here's my working reproduction of the issue: stackblitz/Amiryy/react-hook-form-strictly-typed-poc

So I've composed a NestedInput component:

import { DeepPath, DeepPathValue } from '@hookform/strictly-typed/dist/types';

// NOTE the usage of DeepPath for the types of name and defaultValue props.
// I need these props to match the types of TypedController's name & defaultValue props

interface NestedInputProps<T, Path extends DeepPath<T, Path>> {
  name: Path;
  defaultValue: DeepPathValue<T, Path>
}

function NestedInput<T, Path extends DeepPath<T, Path> = keyof T>(
  props: NestedInputProps<T, Path>
) {
  const { control, handleSubmit } = useFormContext<T>();
  const TypedController = useTypedController<T>({ control });

  return (
    <TypedController
      name={props.name}
      defaultValue={props.defaultValue}
      as={"input"}
    />
  )
}

To make TS accept my code, I had to use imported types such as DeepPath and DeepPathValue for my name and defaultValue props.

As you can see in the snippet above - the second argument of my generic NestedInputProps is Path, which for my understanding represents the path to a nested (or not) property in my form's object type. This type is then used for name prop to match TypedController's name prop's type.

Once I've tried to use my NestedInput inside a form I couldn't avoid having to pass the Path argument:

    {/* 
      MY NESTED EXAMPLES: I am forced to pass a duplication of name prop to make both name 
      and defaultValue props type-safe
    */}
        <NestedInput<FormValues, "flat"> 
          name={"flat"} 
          defaultValue={"flat input"} 
        />
        {/* ❌: not passing additional the argument - TS should complain here on defaultValue being a number but it doesn't */}
        <NestedInput<FormValues /*, "flat" */> 
          name={"flat"} 
          defaultValue={1} 
        />

        <NestedInput<FormValues, ["letters", "a"]> 
          name={["letters", "a"]} 
          defaultValue={"A"} 
        />
        {/* ❌: not passing additional the argument - keyof FormValues is expected by default */}
        <NestedInput<FormValues /*, ["letters", "a"] */> 
          name={["letters", "a"]} 
          defaultValue={"A"} 
        />

        <NestedInput<FormValues, ["letters", "b"]> 
          name={["letters", "b"]} 
          defaultValue={2} 
        />
        {/* ❌: Type 'string' is not assignable to type 'number'. */}
        <NestedInput<FormValues, ["letters", "b"]> 
          name={["letters", "b"]} 
          defaultValue={"B"} 
        />
        {/* ❌: Type '"c"' is not assignable to type '"a" | "b"'. */}
        <NestedInput<FormValues, ["letters", "c"]> 
          name={["letters", "c"]} 
          defaultValue={1} 
        />

It seems like when using <TypedController /> directly the types of name and defaultValue are inferred by their values, so when passing the name prop a property's path, makes defaultValue expect a corresponding type of the property's value.
(from the code example):

{/* ❌: Type '"notExists"' is not assignable to type 'DeepPath<FormValues, "notExists">'. */}
<TypedController as="input" name="notExists" defaultValue="" />

{/* ❌: Type 'number' is not assignable to type 'string | undefined'. */}
<TypedController
    as="input"
    name={['nested', 'object', 0, 'notExists']}
    defaultValue=""
/>

{/* ❌: Type 'true' is not assignable to type 'string | undefined'. */}
<TypedController as="input" name="flat" defaultValue={true} />

And so my bottom-line question is... how can I maintain this usability when passing down name and defaultValue props to a nested <TypedController />?

Thanks!

@kotarella1110
Copy link
Member

@Amiryy Hello, Thanks for your feedback 👍
TypedController supports Context.

const TypedController = useTypedController<FormValues>({});

You don't have to create a NestedInput.

import { useTypedController } from '@hookform/strictly-typed';
import { useForm, FormProvider } from 'react-hook-form';

interface FormValues {
  flat: string;
  count: number;
  letters: {
    a: string,
    b: number
  }
  nested: {
    object: { test: string };
    array: { test: boolean }[];
  };
};

function App() {
  const form = useForm<FormValues>();
  const TypedController = useTypedController<FormValues>({});

  const onSubmit = form.handleSubmit((data) => console.log(data));

  return (
    <FormProvider {...form}>
      <form onSubmit={onSubmit}>
        <TypedController
          name="flat"
          defaultValue=""
          render={(props) => <TextField {...props} />}
        />

        <TypedController
          as="textarea"
          name={['nested', 'object', 'test']}
          defaultValue=""
          rules={{ required: true }}
        />

        <TypedController
          name={['nested', 'array', 0, 'test']}
          defaultValue={false}
          render={(props) => <Checkbox {...props} />}
        />

        {/* ❌: Type '"notExists"' is not assignable to type 'DeepPath<FormValues, "notExists">'. */}
        <TypedController as="input" name="notExists" defaultValue="" />

        {/* ❌: Type 'number' is not assignable to type 'string | undefined'. */}
        <TypedController
          as="input"
          name={['nested', 'object', 0, 'notExists']}
          defaultValue=""
        />

        {/* ❌: Type 'true' is not assignable to type 'string | undefined'. */}
        <TypedController as="input" name="flat" defaultValue={true} />

        <input type="submit" />
      </form>
    </FormProvider>
  );
}

@Amiryy
Copy link
Author

Amiryy commented Aug 7, 2020

@kotarella1110 Thank you for the reply.
Frankly, you didn't really provide a solution for my use case.
My issue here was with maintaining the type-safety of TypedController when it is wrapped in a nested component inside a form.
My example was simple hence didn't practically require nesting the input, but I didn't try to avoid nesting my inputs. I tried to demonstrate to you what happens when I do.
My question is what if I want to nest my inputs? I am trying to integrate this TypedController in my form, and I don't like being forced to render everything directly under my <form> element, the use of excessive typing (see my explanation about the Path type argument) or having to copy-paste the same pattern:

<TypedController
    name={"fieldName"}
    defaultValue={"my default value"}
    render={({ onChange, onBlur, value }) => (
      <FormInputRaw
          onChange={onChange}
          onBlur={onBlur}
          value={value}
        />
    )}
  />

instead of just using a reusable wrapper that is rendering a TypedController around my FormInputRaw:
<FormInput name={"fieldName"} defaultValue={"my default value"} />

TypedController supports Context.

You didn't demonstrate it in any way. In what way does it support and how can it help me here? The code you shared is from the example, I couldn't find any special usage there.

Thanks.

@bluebill1049 bluebill1049 added the question Further information is requested label Aug 17, 2020
@kotarella1110
Copy link
Member

@Amiryy I have two suggestions:

  1. Currying for generics infer
import React from "react";
import { TextField } from "@material-ui/core";
import { useTypedController } from "@hookform/strictly-typed";
import { DeepPath, DeepPathValue } from "@hookform/strictly-typed/dist/types";

type FormInputProps<
  TFieldValues extends Record<string, any>,
  TFieldName extends DeepPath<TFieldValues, TFieldName>
> = {
  name: TFieldName;
  defaultValue: DeepPathValue<TFieldValues, TFieldName>;
};

export const createTypedTextField = <
  TFieldValues extends Record<string, any>
>() => {
  const TypedTextField = <
    UFieldValues extends TFieldValues,
    TFieldName extends DeepPath<UFieldValues, TFieldName>
  >({
    name,
    defaultValue
  }: FormInputProps<UFieldValues, TFieldName>) => {
    const TypedController = useTypedController<TFieldValues>({});
    return (
      <TypedController
        name={name}
        defaultValue={defaultValue}
        render={(props) => <TextField {...props} />}
      />
    );
  };

  return TypedTextField;
};
  1. Pass control object for generics infer
import React from "react";
import { TextField } from "@material-ui/core";
import { useTypedController } from "@hookform/strictly-typed";
import {
  DeepPath,
  DeepPathValue,
  UnpackNestedValue,
  FieldValuesFromControl
} from "@hookform/strictly-typed/dist/types";
import { Control } from "react-hook-form";

type FormInputProps<
  TFieldValues extends Record<string, any>,
  TFieldName extends DeepPath<TFieldValues, TFieldName>,
  TControl extends Control
> = {
  name: TFieldName;
  defaultValue: DeepPathValue<TFieldValues, TFieldName>;
  control: TControl;
};

const TypedTextField = <
  TFieldValues extends UnpackNestedValue<FieldValuesFromControl<TControl>>,
  TFieldName extends DeepPath<TFieldValues, TFieldName>,
  TControl extends Control
>({
  name,
  defaultValue,
  control
}: FormInputProps<TFieldValues, TFieldName, TControl>) => {
  const TypedController = useTypedController({ control });
  return (
    <TypedController
      name={name}
      defaultValue={defaultValue}
      render={(props) => <TextField {...props} />}
    />
  );
};

export default TypedTextField;

CodeSandbox: https://codesandbox.io/s/react-hook-form-usetypedcontroller-14-lvv9r

Does this answer your questions?

@Amiryy
Copy link
Author

Amiryy commented Aug 23, 2020

@kotarella1110 Thank you for your answer, I highly appreciate the effort.
I had preferred solution No.1 over No.2
My goal was to simplify the typings as much as possible while maintaining the type-safety of the input in a reusable way.
No.2 requires passing control down to every input in order to make it work, which seems unnecessary as it is accessible from within the component, via context.
No.1 - Currying for generics infer worked really well for me. it does require an additional declaration but doing it once per file is enough to make all inputs within that file type-safe for my form data.

Eventually I came up with this structure:

interface NestedInputProps<
  FormType extends UnpackNestedValue<FieldValuesFromControl<Control>>, 
  Path extends DeepPath<FormType, Path>, 
> {
  name: Path;
  defaultValue: DeepPathValue<FormType, Path>
}

function createTypedInput<FormType extends Record<string, any>>() {
  return <Path extends DeepPath<FormType, Path>>(props: NestedInputProps<FormType, Path>) => {
    return <NestedInput {...props} />
  }
}

function NestedInput<
  FormType extends UnpackNestedValue<FieldValuesFromControl<Control>>, 
  Path extends DeepPath<FormType, Path>, 
>(
  props: NestedInputProps<FormType, Path>
) {
  const { control, handleSubmit } = useFormContext<FormType>();
  const TypedController = useTypedController<FormType>({ control });

  return (
    <TypedController
      name={props.name}
      defaultValue={props.defaultValue}
      as={'input'}
    />
  )
}

which can be used like this:

const TypedFormInput = createTypedInput<{ someField: string }>();
...
<TypedFormInput name={"someField"} />

I've updated my previous example with this working solution:
https://stackblitz.com/edit/react-hook-form-strictly-typed-poc?file=index.tsx

Thanks!

@esteban-aristizabal
Copy link

esteban-aristizabal commented Dec 4, 2020

I posted an alternative solution for deeply nested structures in case it helps: https://stackblitz.com/edit/react-hook-form-strictly-typed?file=index.tsx

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

4 participants