React/Typescript forwardRef types for an element which returns either an input or textArea

三世轮回 提交于 2021-01-27 04:12:19

问题


I'm trying to create a generic text input component for our application using react and typescript. I want it to be able to either be an input element or a textarea element based on a given prop. So it looks somewhat like this:

import {TextArea, Input} from 'ourComponentLibrary'

export const Component = forwardRef((props, ref) => {
  const Element = props.type === 'textArea' ? TextArea : Input

  return (
    <Element ref={ref} />
  )
})

This code works fine. However, when trying to incorporate types it becomes a little dicey. The ref type should be either HTMLInputElement or HTMLTextAreaElement based on the passed type prop. In my head, it would look something like this:

interface Props {
  ...
}

export const Component = forwardRef<
  HTMLInputElement | HTMLTextAreaElement,
  Props
>((props, ref) => {
  ...
});

However I know this isn't exactly what I need. Hence, the error: Type 'HTMLInputElement' is missing the following properties from type 'HTMLTextAreaElement': cols, rows, textLength, wrap

In summary, I want the types to align so that if the type prop is textArea then the ref type should be HTMLTextAreaElement, and if the type prop is input then the ref type should be HTMLInputAreaElement

Any advice?

Thanks.


回答1:


While this by no means fixes the problem with React.forwardProps, an alternative would be to work around it and instead utilize an innerRef property. Then you can enforce types on the innerRef property. Achieves the same result that you want, but with flexible typing, less overhead, and no instantiation.

Working demo:


components/Label/index.tsx

import * as React from "react";
import { FC, LabelProps } from "~types";

/*
  Field label for form elements

  @param {string} name - form field name
  @param {string} label - form field label 
  @returns {JSX.Element}
*/
const Label: FC<LabelProps> = ({ name, label }) => (
  <label className="label" htmlFor={name}>
    {label}&#58;
  </label>
);

export default Label;

components/Fields/index.tsx

import * as React from "react";
import Label from "../Label";
import { FC, InputProps, TextAreaProps } from "~types";

/*
  Field elements for a form that are conditionally rendered by a fieldType
  of "input" or "textarea".

  @param {Object} props - properties for an input or textarea
  @returns {JSX.Element | null} 
*/
const Field: FC<InputProps | TextAreaProps> = (props) => {
  switch (props.fieldType) {
    case "input":
      return (
        <>
          <Label name={props.name} label={props.label} />
          <input
            ref={props.innerRef}
            name={props.name}
            className={props.className}
            placeholder={props.placeholder}
            type={props.type}
            value={props.value}
            onChange={props.onChange}
          />
        </>
      );
    case "textarea":
      return (
        <>
          <Label name={props.name} label={props.label} />
          <textarea
            ref={props.innerRef}
            name={props.name}
            className={props.className}
            placeholder={props.placeholder}
            rows={props.rows}
            cols={props.cols}
            value={props.value}
            onChange={props.onChange}
          />
        </>
      );
    default:
      return null;
  }
};

export default Field;

components/Form/index.tsx

import * as React from "react";
import Field from "../Fields";
import { FormEvent, FC, EventTargetNameValue } from "~types";

const initialState = {
  email: "",
  name: "",
  background: ""
};

const Form: FC = () => {
  const [state, setState] = React.useState(initialState);
  const emailRef = React.useRef<HTMLInputElement>(null);
  const nameRef = React.useRef<HTMLInputElement>(null);
  const bgRef = React.useRef<HTMLTextAreaElement>(null);

  const handleChange = React.useCallback(
    ({ target: { name, value } }: EventTargetNameValue) => {
      setState((s) => ({ ...s, [name]: value }));
    },
    []
  );

  const handleReset = React.useCallback(() => {
    setState(initialState);
  }, []);

  const handleSubmit = React.useCallback(
    (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();

      const alertMessage = Object.values(state).some((v) => !v)
        ? "Must fill out all form fields before submitting!"
        : JSON.stringify(state, null, 4);

      alert(alertMessage);
    },
    [state]
  );

  return (
    <form className="uk-form" onSubmit={handleSubmit}>
      <Field
        innerRef={emailRef}
        label="Email"
        className="uk-input"
        fieldType="input"
        type="email"
        name="email"
        onChange={handleChange}
        placeholder="Enter email..."
        value={state.email}
      />
      <Field
        innerRef={nameRef}
        label="Name"
        className="uk-input"
        fieldType="input"
        type="text"
        name="name"
        onChange={handleChange}
        placeholder="Enter name..."
        value={state.name}
      />
      <Field
        innerRef={bgRef}
        label="Background"
        className="uk-textarea"
        fieldType="textarea"
        rows={5}
        name="background"
        onChange={handleChange}
        placeholder="Enter background..."
        value={state.background}
      />
      <button
        className="uk-button uk-button-danger"
        type="button"
        onClick={handleReset}
      >
        Reset
      </button>
      <button
        style={{ float: "right" }}
        className="uk-button uk-button-primary"
        type="submit"
      >
        Submit
      </button>
    </form>
  );
};

export default Form;

types/index.ts

import type {
  FC,
  ChangeEvent,
  RefObject as Ref,
  FormEvent,
  ReactText
} from "react";

// custom utility types that can be reused
type ClassName = { className?: string };
type InnerRef<T> = { innerRef?: Ref<T> };
type OnChange<T> = { onChange: (event: ChangeEvent<T>) => void };
type Placeholder = { placeholder?: string };
type Value<T> = { value: T };

// defines a destructured event in a callback
export type EventTargetNameValue = {
  target: {
    name: string;
    value: string;
  };
};

/*
  Utility interface that constructs typings based upon passed in arguments

  @param {HTMLElement} E - type of HTML Element that is being rendered
  @param {string} F - the fieldType to be rendered ("input" or "textarea")
  @param {string} V - the type of value the field expects to be (string, number, etc)
*/
interface FieldProps<E, F, V>
  extends LabelProps,
    ClassName,
    Placeholder,
    OnChange<E>,
    InnerRef<E>,
    Value<V> {
  fieldType: F;
}

// defines props for a "Label" component
export interface LabelProps {
  name: string;
  label: string;
}

// defines props for an "input" element by extending the FieldProps interface
export interface InputProps
  extends FieldProps<HTMLInputElement, "input", ReactText> {
  type: "text" | "number" | "email" | "phone";
}

// defines props for an "textarea" element by extending the FieldProps interface
export interface TextAreaProps
  extends FieldProps<HTMLTextAreaElement, "textarea", string> {
  cols?: number;
  rows?: number;
}

// exporting React types for reusability
export type { ChangeEvent, FC, FormEvent };

index.tsx

import * as React from "react";
import { render } from "react-dom";
import Form from "./components/Form";
import "uikit/dist/css/uikit.min.css";
import "./index.css";

render(<Form />, document.getElementById("root"));



回答2:


This is a tricky one, and the only viable way I can think to do this is with a higher order component and function overloading.

Basically, we have to create a function that will itself return one type of component or the other depending on what argument its passed.

// Overload signature #1
function MakeInput(
  type: "textArea"
): React.ForwardRefExoticComponent<
  TextAreaProps & React.RefAttributes<HTMLTextAreaElement>
>;
// Overload signature #2
function MakeInput(
  type: "input"
): React.ForwardRefExoticComponent<
  InputProps & React.RefAttributes<HTMLInputElement>
>;
// Function declaration
function MakeInput(type: "textArea" | "input") {
  if (type === "textArea") {
    const ret = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
      (props, ref) => {
        return <TextArea {...props} ref={ref} />;
      }
    );
    return ret;
  } else {
    const ret = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
      return <Input {...props} ref={ref} />;
    });
    return ret;
  }
}

Then, instantiate the component type you want to render by calling the higher order component function MakeInput() with the "type" of the component:

export default function App() {
  const textAreaRef = React.useRef<HTMLTextAreaElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);

  const MyTextArea = MakeInput("textArea");
  const MyInput = MakeInput("input");

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      <MyTextArea value={"Foo"} ref={textAreaRef} />
      <MyInput value={"Bar"} ref={inputRef} />
    </div>
  );
}

Now, this may feel "unsatisfying" because this is roughly equivalent to doing a conditional check here to see what type of component to render based on type, just abstracted away into a function. But, you can't render a magical <MyTextAreaOrInputComponent /> and get full type checking on both its props and ref attributes. And for that, you'll have to blame React itself, because the ref prop, like key and possibly some other props, are very very special and treated uniquely by React, which is exactly what necessitates React.forwardRef() in the first place.

But if you think about it, in practical terms you're still getting the prop type checking that you are looking for, it's just that you add an extra step of calling MakeInput() to determine the component type. So instead of writing this:

return <Component type="textArea" ref={textAreaRef} />

You're writing this:

const MyComponent = MakeInput("textArea");

return <MyComponent ref={textAreaRef} />

In both cases, you clearly must know the value of both type and ref at the time you are writing your code. The former case is impossible to get working (to my knowledge) because of the way React.forwardRef() works. But the latter case is possible, and gives you the exact same level of type checking, just with the extra step.

https://codesandbox.io/s/nostalgic-pare-pqmfu?file=/src/App.tsx

Note: play around with the sandbox above and see how even though <Input/> has an additional prop extraInputValue compared to <TextArea/>, the higher order component handles it gracefully. Also note that calling MakeInput() with either valid string value to create a component results in the expected and proper prop type checking.

Edit: Another illustration of how a "magic bullet" component vs. using a HOC are functionally identical in terms of type checking, since in your scenario you know both the type and what HTML element the ref should represent at pre-compile-time, you could literally just do this IIFE which contains the same amount of information:

  return <div>
      {(function(){
        const C = MakeInput("textArea");
        return <C value={"Baz"} ref={textAreaRef} />
      })()}
  </div>;


来源:https://stackoverflow.com/questions/63963850/react-typescript-forwardref-types-for-an-element-which-returns-either-an-input-o

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!