Writing a React higher-order component with TypeSc

2019-04-06 13:07发布

问题:

I'm writing a React higher-order component (HOC) with TypeScript. The HOC should accept one more prop than the wrapped component, so I wrote this:

type HocProps {
    // Contains the prop my HOC needs
    thingy: number
}
type Component<P> = React.ComponentClass<P> | React.StatelessComponent<P>
interface ComponentDecorator<TChildProps> {
    (component: Component<TChildProps>): Component<HocProps & TChildProps>;
}
const hoc = function<TChildProps>(): (component: Component<TChildProps>) => Component<HocProps & TChildProps) {
    return (Child: Component<TChildProps>) => {
        class MyHOC extends React.Component<HocProps & TChildProps, void> {
            // Implementation skipped for brevity
        }
        return MyHOC;
    }
}
export default hoc;

In other words, hoc is a function that yields the actual HOC. This HOC is (I believe) a function that accepts a Component. Since I don't know in advance what the wrapped component will be, I'm using a generic type TChildProps to define the shape of the props of the wrapped component. The function also returns a Component. The returned component accepts props for the wrapped component (again, typed using the generic TChildProps) and some props it needs for itself (type HocProps). When using the returned component, all of the props (both HocProps and the props for the wrapped Component) should be supplied.

Now, when I attempt to use my HOC, I do the following:

// outside parent component
const WrappedChildComponent = hoc()(ChildComponent);

// inside parent component
render() {
    return <WrappedChild
                thingy={ 42 } 
                // Prop `foo` required by ChildComponent
                foo={ 'bar' } />
}

But I get a TypeScript error:

TS2339: Property 'foo' does not exist on type 'IntrinsicAttributes & HocProps & {} & { children? ReactNode; }'

It seems to me TypeScript is not replacing TChildProps with the shape the of the props needed for ChildComponent. How can I make TypeScript do that?

回答1:

If what you're asking for is if it's possible to define a HOC that can add a new prop, let's say "thingy", to a component without modifying that component's props definition to include "thingy" I think that's impossible.

That's because at some point in the code you'll end up with:

render() {
    return (
        <WrappedComponent thingy={this.props.thingy} {...this.props}/>
    );
}

And that will always throw an error if WrappedComponent does not include thingy in its props definition. The child has to know what it receives. Off hand, I can't think of a reason for passing a prop to a component that doesn't know about to it anyway. You wouldn't be able to reference that prop in the child component without an error.

I think the trick is to define the HOC as a generic around the props of the child and then to just include your prop thingy or whatever in that child's interface explicitly.

interface HocProps {
    // Contains the prop my HOC needs
    thingy: number;
}

const hoc = function<P extends HocProps>(
    WrappedComponent: new () => React.Component<P, any>
) {
    return class MyHOC extends React.Component<P, any> {
        render() {
            return (
                <WrappedComponent {...this.props}/>
            );
        }
    }
}
export default hoc;

// Example child class

// Need to make sure the Child class includes 'thingy' in its props definition or
// this will throw an error below where we assign `const Child = hoc(ChildClass)`
interface ChildClassProps {
    thingy: number;
}

class ChildClass extends React.Component<ChildClassProps, void> {
    render() {
        return (
            <h1>{this.props.thingy}</h1>
        );
    }
}

const Child = hoc(ChildClass);

Now of course this example HOC doesn't really do anything. Really HOC's should be doing some sort of logic to provide a value for the child prop. Like for example maybe you have a component that displays some generic data that gets updated repeatedly. You could have different ways it gets updated and create HOC's to separate that logic out.

You have a component:

interface ChildComponentProps {
    lastUpdated: number;
    data: any;
}

class ChildComponent extends React.Component<ChildComponentProps, void> {
    render() {
        return (
            <div>
                <h1>{this.props.lastUpdated}</h1>
                <p>{JSON.stringify(this.props.data)}</p>
            </div>
        );
    }
}

And then an example HOC that just updates the child component on a fixed interval using setInterval might be:

interface AutoUpdateProps {
    lastUpdated: number;
}

export function AutoUpdate<P extends AutoUpdateProps>(
    WrappedComponent: new () => React.Component<P, any>,
    updateInterval: number
) {
    return class extends React.Component<P, any> {
        autoUpdateIntervalId: number;
        lastUpdated: number;

        componentWillMount() {
            this.lastUpdated = 0;
            this.autoUpdateIntervalId = setInterval(() => {
                this.lastUpdated = performance.now();
                this.forceUpdate();
            }, updateInterval);
        }

        componentWillUnMount() {
            clearInterval(this.autoUpdateIntervalId);
        }

        render() {
            return (
                <WrappedComponent lastUpdated={this.lastUpdated} {...this.props}/>
            );
        }
    }
}

Then we could create a component that updates our child once every second like this:

const Child = AutoUpdate(ChildComponent, 1000);


回答2:

I found one way to make it work: by invoking the hoc with the type argument supplied, like so:

import ChildComponent, { Props as ChildComponentProps } from './child';
const WrappedChildComponent = hoc<ChildComponentProps>()(ChildComponent);

But I don't really like it. It requires me to export the props of the child (which I'd rather not do) and I have the feeling I'm telling TypeScript something it should be able to infer.



回答3:

Your problem is that hoc is generic, but takes no parameters—there’s no data to infer anything from. That forces you to explicitly specify the type for hoc when you call it. Even if you do hoc()(Foo), Typescript has to evaluate hoc() first—and there’s zero information to go on at that point. Hence needing hoc<FooProps>()(Foo).

If the body of hoc doesn’t require the type and can be flexible on that point, you could make hoc not be generic, but instead return a generic function (the higher-order component).

That is, instead of

const hoc = function<TChildProps>(): (component: Component<TChildProps>) => Component<HocProps & TChildProps) {
    return (Child: Component<TChildProps>) => {

you could instead have

const hoc = function(): <TChildProps>(component: Component<TChildProps>) => Component<HocProps & TChildProps) {
    return <TChildProps>(Child: Component<TChildProps>) => {

(note the placement of <TChildProps>)

Then when you call hoc(), you get a generic function, and when you call hoc()(Foo), it will be inferred to be equivalent to hoc()<FooProps>(Foo).

This does not help you if hoc itself needs to do something with the type parameter—if it does, you are saying “give me a function that will only accept arguments that are components with this type as a property,” and you need to give it that type then. Whether or not this helps depends on what the actual hoc function is doing, which isn’t demonstrated in your question.

I assume that your actual use-case is doing something in hoc, rather than just immediately returning the higher-order component function. Certainly, if not, you should just remove the layer of indirection and have there just be the higher-order component. Even if you are doing things inside hoc, I would strongly suggest that there is a pretty good chance you are better off just not doing that, moving that functionality to within the actual higher-order component, and eliminating the indirection. Your approach only makes sense if you are calling hoc once or a few times, but then using the resulting hoc() function(s) many, many times with different parameters—and even then, this would be an optimization that may well be premature.