Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Generic type arguments in JSX elements with withStyles

In React with material-ui I am trying to create a JSX component that accepts generic parameters and also uses the withStyles HOC to inject my styles.

The first approach was like this:

const styles = (theme: Theme) => createStyles({
  card:    {
    ...
  }
});

interface Props<T> {
  prop: keyof T,
  ...
}

type PropsWithStyles<T> = Props<T> & WithStyles<typeof styles>;

export default withStyles(styles)(
    class BaseFormCard<T> extends React.Component<PropsWithStyles<T>> {
      ...
    }
),

But when trying to use this, the generic types are lost

<BaseFormCard<MyClass> prop={ /* no typings here */ } />

The only solution I could find was to wrap the export in a function which takes the generic parameter and constructs the component.

export default function WrappedBaseFormCard<T>(props: Props<T>): ReactElement<Props<T>> {

  const wrapper = withStyles(styles)(
        class BaseFormCard<T> extends React.Component<PropsWithStyles<T>> {
          ...
        }
    ) as any;

  return React.createElement(wrapper, props);
}

However this is ridiculously complicated and even comes with runtime cost, although it is only trying to solve problems typings.

There has to be a better way to use JSX components with generic parameters and HOCs.

This is closely related to the issue here https://github.com/mui-org/material-ui/issues/11921, but there was never satisfying solution and the issue is now closed.

like image 853
Lukas Zech Avatar asked Sep 29 '18 10:09

Lukas Zech


2 Answers

The more I think about the question, the more I like Frank Li's approach. I'd make two modifications: (1) introduce an extra SFC to avoid a cast, and (2) grab the outer props type from the wrapped component C instead of hard-coding it. (If we hard-coded Props<T>, TypeScript would at least check that it is compatible with this.C, but we are at risk of requiring props that this.C doesn't actually require or failing to accept optional props that this.C actually accepts.) It's jaw-dropping that referencing a property type from a type argument in the extends clause works, but it seems to!

class WrappedBaseFormCard<T> extends React.Component<
  // Or `PropsOf<WrappedBaseFormCard<T>["C"]>` from @material-ui/core if you don't mind the dependency.
  WrappedBaseFormCard<T>["C"] extends React.ComponentType<infer P> ? P : never,
  {}> {
  private readonly C = withStyles(styles)(
    // JSX.LibraryManagedAttributes handles defaultProps, etc.  If you don't
    // need that, you can use `BaseFormCard<T>["props"]` or hard-code the props type.
    (props: JSX.LibraryManagedAttributes<typeof BaseFormCard, BaseFormCard<T>["props"]>) =>
      <BaseFormCard<T> {...props} />);
  render() {
    return <this.C {...this.props} />;
  }
}

I think any complaints about runtime overhead of this approach are probably nonsense in the context of a whole React application; I'll believe them when someone presents data supporting them.

Note that Lukas Zech's approach using an SFC is very different: every time the props to the outer SFC change and it is called again, withStyles is called again, generating a wrapper that looks to React like a whole new component type, so React throws away the old wrapper instance and a new inner BaseFormCard component gets created. This would have undesirable behavior (resetting state), not to mention greater runtime overhead. (I haven't actually tested this, so let me know if I'm missing something.)

like image 188
Matt McCutchen Avatar answered Nov 15 '22 11:11

Matt McCutchen


This works enough for me using Visual Studio:

import React from "react";
import { withStyles, WithStyles } from "@material-ui/styles";

const styles = {
  root: { ... },
};

export interface Props<T> { ... }
export interface State<T> { ... }

export class TableComponent<T> extends React.PureComponent<Props<T> & WithStyles<typeof styles>, State<T>> {
  render () {
    return <div className={this.props.classes.root} />;
  }
}

export const Table = (withStyles(styles)(TableComponent) as any) as new <T>() => TableComponent<T>;

export default Table;
like image 44
jgdev Avatar answered Nov 15 '22 12:11

jgdev