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:
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}:
</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"));