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

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


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:

Edit Typescript - Switch Component


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 reusedtype 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 callbackexport 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" componentexport interface LabelProps {  name: string;  label: string;}// defines props for an "input" element by extending the FieldProps interfaceexport interface InputProps  extends FieldProps<HTMLInputElement, "input", ReactText> {  type: "text" | "number" | "email" | "phone";}// defines props for an "textarea" element by extending the FieldProps interfaceexport interface TextAreaProps  extends FieldProps<HTMLTextAreaElement, "textarea", string> {  cols?: number;  rows?: number;}// exporting React types for reusabilityexport 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"));


I know I'm really late to answer this, but this is how I solved this problem. Maybe this will help someone else someday.

type InputElement = 'input' | 'textarea'export type InputProps<E extends InputElement> = {    multiline: E extends 'textarea' ? true : false    /* rest of props */}const Component = React.forwardRef(function Component<E extends InputElement>(    props: InputProps<E>,    ref: React.Ref<HTMLElementTagNameMap[E] | null>,) {


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 #1function MakeInput(  type: "textArea"): React.ForwardRefExoticComponent<  TextAreaProps & React.RefAttributes<HTMLTextAreaElement>>;// Overload signature #2function MakeInput(  type: "input"): React.ForwardRefExoticComponent<  InputProps & React.RefAttributes<HTMLInputElement>>;// Function declarationfunction 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>;