TypeScript struggles with Redux containers

2019-02-08 11:13发布

问题:

I'm having some trouble figuring out how to properly type Redux containers.

Consider a simple presentational component that might look like this:

interface MyProps {
  name: string;
  selected: boolean;
  onSelect: (name: string) => void;
}
class MyComponent extends React.Component<MyProps, {}> { }

From the perspective of this component all props are required.

Now I want to write a container that pulls all these props out of state:

function mapStateToProps(state: MyState) {
  return {
    name: state.my.name,
    selected: state.my.selected
  };
}

function mapDispatchToProps(dispatch: IDispatch) {
  return {
    onSelect(name: string) {
      dispatch(mySelectAction(name));
    }
  };
}

const MyContainer = connect(
  mapStateToProps,
  mapDispatchToProps
)(MyComponent);

This works, but there's a big typing problem: the mapping functions (mapStateToProps and mapDispatchToProps) have no protection that they are providing the right data to fulfill MyProps. This is prone to error, typos and poor refactoring.

I could make the mapping functions return type MyProps:

function mapStateToProps(state: MyState): MyProps { }

function mapDispatchToProps(dispatch: IDispatch): MyProps { }

However this doesn't work unless I make all MyProp props optional, so that each mapping function can return only the portion they care about. I don't want to make the props optional, because they aren't optional to the presentational component.

Another option is to split up the props for each map function and combine them for the component props:

// MyComponent
interface MyStateProps {
  name: string;
  selected: boolean;
}

interface MyDispatchProps {
  onSelect: (name: string) => void;
}

type MyProps = MyStateProps & MyDispatchProps;

class MyComponent extends React.Component<MyProps, {}> { }

// MyContainer
function mapStateToProps(state: MyState): MyStateProps { }

function mapDispatchToProps(dispatch: IDispatch): MyDispatchProps { }

Ok, that's getting me closer to what I want, but its kind of noisy and its making me write my presentational component prop interface around the shape of a container, which I don't like.

And now a second problem arises. What if I want to put the container inside another component:

<MyContainer />

This gives compile errors that name, selected, and onSelect are all missing... but that's intentional because the container is connecting to Redux and providing those. So this pushes me back to making all component props optional, but I don't like that because they aren't really optional.

Things get worse when MyContainer has some of its own props that it wants to be passed in:

<MyContainer section="somethng" />

Now what I'm trying to do is have section a required prop of MyContainer but not a prop of MyComponent, and name, selected, and onSelect are required props of MyComponent but optional or not props at all of MyContainer. I'm totally at a loss how to express this.

Any guidence on this would be appreciated!

回答1:

You're on the right track with your last example. What you also need to define is a MyOwnProps interface, and type the connect function.

With these typings: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/react-redux/react-redux.d.ts, you can do something like this

interface MyProps {
  section: string;
  name: string;
  selected: boolean;
  onSelect: (name: string) => void;
}

interface MyStateProps {
  name: string;
  selected: boolean;
}

interface MyDispatchProps {
  onSelect: (name: string) => void;
}

interface MyOwnProps {
  section: string;
}

class MyComponent extends React.Component<MyProps, {}> { }

function mapStateToProps(state: MyState): MyStateProps { }

function mapDispatchToProps(dispatch: IDispatch): MyDispatchProps { }

const MyContainer = connect<MyStateProps, MyDispatchProps, MyOwnProps>(
  mapStateToProps,
  mapDispatchToProps
)(MyComponent);

This also lets you type ownProps in mapStateToProps and mapDispatchToProps, e.g.

function mapStateToProps(state: MyState, ownProps: MyOwnProps): MyStateProps

You don't need to define an intersection type for MyProps type as long as you define dispatch, state, and ownProps types. That way you can keep MyProps in its own place and let containers, or places where the component is used without containers, apply the props as they need to. I guess this is up to you and your use case - if you define MyProps = MyStateProps & MyDispatchProps & MyOwnProps, it's tied to one specific container, which is less flexible (but less verbose).

As a solution, it is pretty verbose, but I don't think there's any way of getting around telling TypeScript that the different pieces of required props will be assembled in different places, and connect will tie them together.

Also, for what it's worth, I have typically gone with optional props, for simplicity's sake, so I don't have much in the way of experience to share on using this approach.



回答2:

I just use Partial<MyProps>, where Partial is a built-in TypeScript type defined as:

type Partial<T> = {
    [P in keyof T]?: T[P];
}

It takes an interface and makes every property in it optional.

Here's an example of a presentational/Redux-aware component pair I've written:

/components/ConnectionPane/ConnectionPane.tsx

export interface IConnectionPaneProps {
  connecting: boolean;
  connected: boolean;
  onConnect: (hostname: string, port: number) => void;
}

interface IState {
  hostname: string;
  port?: number;
}

export default class ConnectionPane extends React.Component<IConnectionPaneProps, IState> {
   ...
}

/containers/ConnectionPane/ConnectionPane.ts

import {connect} from 'react-redux';
import {connectionSelectors as selectors} from '../../../state/ducks';
import {connect as connectToTestEcho} from '../../../state/ducks/connection';
import {ConnectionPane, IConnectionPaneProps} from '../../components';

function mapStateToProps (state): Partial<IConnectionPaneProps> {
  return {
    connecting: selectors.isConnecting(state),
    connected: selectors.isConnected(state)
  };
}

function mapDispatchToProps (dispatch): Partial<IConnectionPaneProps> {
  return {
    onConnect: (hostname, port) => dispatch(connectToTestEcho(hostname, port))
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(ConnectionPane) as any;

The presentational component props are non-optional - exactly as required for the presentation without any bearing on the corresponding "smart" component.

Meanwhile, mapStateToProps and mapDispatchToProps will allow me to assign a subset of the required presentational props in each function, while flagging any props not defined in the presentational props interface as an error.



回答3:

interface MyStateProps {
    name: string;
    selected: boolean;
}

interface MyDispatchProps {
    onSelect: (name: string) => void;
}

interface MyOwnProps {
    section: string;
}

// Intersection Types
type MyProps = MyStateProps & MyDispatchProps & MyOwnProps;


class MyComponent extends React.Component<MyProps, {}> { }

function mapStateToProps(state: MyState): MyStateProps { }

function mapDispatchToProps(dispatch: IDispatch): MyDispatchProps { }

const MyContainer = connect<MyStateProps, MyDispatchProps, MyOwnProps>(
  mapStateToProps,
  mapDispatchToProps
)(MyComponent);

You can use something use called Intersection Types https://www.typescriptlang.org/docs/handbook/advanced-types.html#intersection-types