8/5/2021
There are two main patterns how to separate concerns into different components in React. Either - given the concrete implementation to be wrapped - you create a new wrapped component as described in React's Guide on Higher-Order Components ...
jsxfunction createNewWrappedComponent(WrappedComponent) {return function Wrapper({title, children, ...others}) {return <WrappedComponent {...others}><h3>{title}</h3>{children}</WrappedComponent>}}const DivWithTitle = createNewWrappedComponent("div")render(<DivWithTitle title="foo" className="bar">baz</DivWithTitle>)// results in<div className="bar"><h3>foo</h3>baz</div>
... or you accept a component as a prop - making the concrete implementation for the wrapped component injectable.
jsxfunction Wrapper({WrappedComponent, title, children, ...others}) {return <WrappedComponent {...others}><h3>{title}</h3>{children}</WrappedComponent>}render(<Wrapper WrappedComponent="div" title="foo" className="bar">bar</Wrapper>)// results in<div className="bar"><h3>foo</h3>baz</div>
When working with Typescript you want all component props to be typed - especially on reused code, no matter if you set the WrappedComponent beforehand or per prop.
The problem
One can infer props from the injected WrappedComponent like this:
tsxfunction Wrapper<T extends React.ElementType<{children?: React.ReactNode}>,>({WrappedComponent, title, children, ...others}: {WrappedComponent: Ttitle: string} & Omit<React.ComponentPropsWithoutRef<T>,'WrappedComponent' // prevent type widening on this prop'title' // remove any other props we don't pass down>): React.Element|null {/// ...}
Ref is not a regular prop
As React's Guide on Forwarding Refs points out ref it is not passed to regular components.
When one tries to have a Wrapper accept a ref because one wants it to mimic the behavior of the WrappedComponent so it can replace it in some context, it needs to be passed through React.forwardRef.
jsxconst ForwardingWrapper = React.forwardRef(function Wrapper({WrappedComponent, title, children, ...others},ref,) {return <WrappedComponent {...others} ref={ref}><h3>{title}</h3>{children}</WrappedComponent>})
This ForwardingWrapper infers its props and types from Wrapper but if our Wrapper is generically typed like described above this does not work and we end up with basically untyped props (and ref) on ForwardingWrapper.
And without knowing the WrappedComponent beforehand one can not set the types for ref and props on React.forwardRef<T, P>().
Concrete types don't match generics
The Wrapper as types above has a WrappedComponent variable that is typed to T which extends React.Elements - but it is not React.Elements because it could be a different subtype.
(If this does not ring a bell consider yourself lucky. I guess stumbling over ts2322 at some point and feeling stupid for not seeing the problem at first glance before Typescript yelled is part of the Typescript experience. ;) )
tsxconst element = <WrappedComponent // this is T// Props must match ComponentProps<T> & Attributes// But Typescript can not decide if this is the case/>
Of course one could work around this per React.createElement because its props type defaults to any. But this kind of defeats the purpose.
The solution
By typing ForwardingWrapper as a component function that infers its props (including ref) from one of its own props and using Typescript's inference from usage for the implementation of Wrapper we get the correct types both inside and outside our implementation.
First we create generic types for props and ref:
ts/*** Infer the ForwardedRef from a component*/export type ComponentForwardRef<T extends React.ElementType>= React.ForwardedRef<React.ElementRef<T>>/*** Properties of a wrapper component*/export type WrapperProps</*** The property key for the component to be wrapped.*/ComponentProp extends PropertyKey,/*** Additional properties for the wrapper.*/AdditionalProps extends unknown,/*** The property key for properties passed down to the wrapped component.* `unknown` if properties of wrapper and wrapped are merged.*/PropertiesProp extends PropertyKey | unknown,/*** The wrapped component.*/Component extends React.ElementType,> = {[k in ComponentProp]: Component} & (PropertiesProp extends PropertyKey? { [k in PropertiesProp]: React.ComponentProps<Component> }: Omit<React.ComponentProps<Component>, keyof AdditionalProps | ComponentProp>) & AdditionalProps
We also prepare a generic type that properly describes ForwardingWrapper:
tsexport interface ForwardRefWrapper<ComponentProp extends PropertyKey,AdditionalProps extends unknown = unknown,PropertiesProp extends PropertyKey | unknown = unknown,/*** Base type for the wrapped component.*/ComponentType extends React.ElementType = React.ElementType,> extends OmitCallable<React.ForwardRefExoticComponent<any>> {<T extends ComponentType>(props: WrapperProps<ComponentProp, AdditionalProps, PropertiesProp, T>,): React.ReactElement | null}type OmitCallable<T> = Pick<T, keyof T>
With these types we are ready to write a utility function to create React.ForwardRefExoticComponent with our infered typing:
tsfunction createForwardRefWrapper<ComponentProp extends PropertyKey,AdditionalProps extends unknown = unknown,PropertiesProp extends PropertyKey | unknown = unknown,ComponentType extends React.ElementType = React.ElementType,>(wrapper: (props: WrapperProps<ComponentProp, AdditionalProps, PropertiesProp, ComponentType>,ref: ComponentForwardRef<ComponentType>) => React.ReactElement | null,): ForwardRefWrapper<ComponentProp, AdditionalProps, PropertiesProp, ComponentType> {return React.forwardRef(wrapper)}
Now we can easily write a type safe wrapper with forwarded ref:
tsxconst ForwardingWrapper = createForwardRefWrapper<'WrappedComponent',{title: string}>(function Wrapper({WrappedComponent, title, children, ...others}, ref) {return (<WrappedComponent{...others}ref={ref}><h3>{title}</h3>{children}</WrappedComponent>)})
You can import createForwardRefWrapper and the utility types from liform-util.