Redux + TypeScript and mapDispatchToProps

Viewed 1648

This should be easy but it's eluding me at the moment but I feel like it should be a simple fix. I am currently using redux with typescript and using redux-thunk for async action creators.

The set up is simple. Here is the code I am using for login:

export function requestAuthenticationAsync(email: string, password: string) {
    return (dispatch: ThunkDispatch<IState, undefined, IAction>): Promise<void> => {
        dispatch(requestAuthentication());

        return postAuthentication(email, password).then((response) => {
            dispatch(receiveAuthentication());

            return response.json();
        }).then((data) => {
            dispatch(receiveUser(data));
        });
    };
}

The ideal situation is that I could call this in a .tsx file using .then to navigate elsewhere when the login is successful.

So, this works as you would expect it to when I do something like this in a component:

const { dispatch } = store;

dispatch(requestAuthenticationAsync('email', 'password')).then(() => {
    // navigate somewhere
});

However, when I use connect and mapDispatchToProps from react-redux like so:

import './Gateway.scss';
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { requestAuthenticationAsync } from './actions';
import { withRouter } from 'react-router-dom';

const mapDispatchToProps = (dispatch) => {
    return {
        requestAuthenticationAsync: bindActionCreators(requestAuthenicationAsync, dispatch)
    };
};

const mapStateToProps = (state) => {
    return {
        authenticated: state.authentication.authenticated
    };
};

class Gateway extends React.Component<{
    authenticated: boolean;
    requestAuthenticationAsync: typeof requestAuthenticationAsync;
}, {
    email: string;
    password: string;
}> {

    constructor(props) {
        super(props);

        this.state = {
            email: '',
            password: ''
        };
    }

    onGatewaySubmit = (event) => {
        event.preventDefault();

        const { requestAuthenticationAsync } = this.props;
        const { email, password } = this.state;

        requestAuthenticationAsync(email, password).then(() => {
            console.log('done');
        });
    };

    onEmailValueChange = (event) => {

        this.setState({
            email: event.target.value
        });
    };

    onPasswordValueChange = (event) => {
        this.setState({
            password: event.target.value
        });
    };

    render() {
        return (
            <div id='gateway'>
                <form onSubmit={ this.onGatewaySubmit }>
                    <input
                        className='email'
                        onChange={ this.onEmailValueChange }
                        placeholder='email'
                        type='text' />
                    <input
                        className='password'
                        onChange={ this.onPasswordValueChange }
                        placeholder='password'
                        type='password' />
                    <input type='submit' value='Submit' />
                </form>
            </div>
        );
    }
}

export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Gateway));

I get the following error:

TS2339: Property 'then' does not exist on type '(dispatch: ThunkDispatch<IState, undefined, IAction>) => Promise<void>'.

What gives? How do I make TypeScript happy in this situation so I can use a promise with .then?

3 Answers

The root cause of the problem is that redux-thunk is middleware that is executed by redux so it's invoking the function (thunk) and returning the value. However, TypeScript isn't "aware" that's happening so there's no way for it to be properly typed (without a little extra work).

The redux-thunk package (at this time) actually ships with type definitions. However, there have been a number of big improvements to its type definitions but no releases. It sounds like in the 3.0 release they will be removed and moved to DefinitelyTyped (installable via @types/redux-thunk).

But until then, you can setup the types yourself. If you compare what is released today versus what is in the repo there is comparatively more type definitions.

To use these (before they get released in a new version of redux-thunk or DefinitelyTyped), you can create a types file (eg: types.d.ts) with the following:

import { ActionCreatorsMapObject } from "redux";
import { ThunkAction } from "redux-thunk";

/**
 * Redux behaviour changed by middleware, so overloads here
 */
declare module "redux" {
  /**
   * Overload for bindActionCreators redux function, returns expects responses
   * from thunk actions
   */
  function bindActionCreators<
    TActionCreators extends ActionCreatorsMapObject<any>
  >(
    actionCreators: TActionCreators,
    dispatch: Dispatch
  ): {
    [TActionCreatorName in keyof TActionCreators]: ReturnType<
      TActionCreators[TActionCreatorName]
    > extends ThunkAction<any, any, any, any>
      ? (
          ...args: Parameters<TActionCreators[TActionCreatorName]>
        ) => ReturnType<ReturnType<TActionCreators[TActionCreatorName]>>
      : TActionCreators[TActionCreatorName]
  };
}

This is pulled directly from what is in the repo today. If you need more, you could copy the whole file but this piece should solve your problem.

Then, update your call to bindActionCreators to pass an object and infer those types (this isn't strictly necessary for the mapStateToProps, but I find it a bit easier to avoid the "double" typing):

type DispatchProps = ReturnType<typeof mapDispatchToProps>;
const mapDispatchToProps = dispatch => {
  return bindActionCreators({ requestAuthenticationAsync }, dispatch);
};

type StateProps = ReturnType<typeof mapStateToProps>;
const mapStateToProps = state => ({
  authenticated: state
});

type Props = DispatchProps & StateProps;

class Gateway extends React.Component<Props> {
  // ...
}

The types could be updated, but with the types in the redux-thunk repo today, they expect the first argument to bindActionCreators to be an object (although the docs say it can be either a function as you were using or an object) by looking at TActionCreators extends ActionCreatorsMapObject<any>.

This should now properly type this.props.requestAuthenticationAsync for use in your component.

onGatewaySubmit = event => {
  event.preventDefault();

  const { requestAuthenticationAsync } = this.props;
  const { email, password } = this.state;

  // Type:
  //   (email: string, password: string) => Promise<void>
  requestAuthenticationAsync(email, password).then(() => {
    console.log("done");
  });
};

I want to check what I'm understanding and leaving here since I can't write comments.

If you call requestAuthenticationAsync in the function onGatewaySubmit of your code like this;

requestAuthenticationAsync(email, password).then(() => {
    console.log('done');
});

I feel that you are calling the dispatched function like below

dispatch(requestAuthenticationAsync)(email, password).then(() => {
  // navigate somewhere
});

Not expected one as you wrote

dispatch(requestAuthenticationAsync('email', 'password')).then(() => {
  // navigate somewhere
});

It could be off-topic but could you explain the flow?

This was actually a very simple solution that I must've overlooked. The types in the component for the particular prop was wrong.

Instead of:

requestAuthenticationAsync: typeof requestAuthenticationAsync;

I changed it to:

requestAuthenticationAsync: ReturnType<requestAuthenticationAsync>;

This was able to capture the (email: string, password: string) => Promise<void> type and the compiler stopped complaining and everything worked.

Related