TypeScript で React Redux connect を使うときの忘備録

この記事では、 TypeScript で React Reduxconnect を使う際の知見を忘備録として載せます。

React Redux 自体は他の方の投稿のほうが詳しく紹介されているので、この記事では connect を使うときに型を何度も書かない方法と、 connect されたコンポーネントのテストについて書きます。
Preact Redux でも、型定義のバグが無ければ多分同じように使えます。

React Redux は、 connect という関数またはデコレータで Redux の state を React の props へ結びつけるライブラリです。

connect で型の記述を減らす

コンポーネント Container に対して connect する場合は、それぞれの方法で以下のように書きます。
(私の手元ではデコレータの方は型がうまくハマりませんでした。 Issue にも挙げられています。)

1
2
3
4
export default connect(mapStateToProps, mapDispatchToProps, mergeProps, options)(Container);

@connect(mapStateToProps, mapDispatchToProps, mergeProps, options)
export class Container extends React.Component<Props> {}

TypeScript の場合では、コンポーネントに指定した Property の型の中から mapStateToPropsmapDispatchToProps が返すオブジェクトの型のフィールドを抜いた型が外に出ていくことになります。( mergeProps が定義されていれば、それが返すオブジェクトの型のフィールドが代わりに抜かれる。)
例えば、以下のように書いた場合、これの利用先では fooBar 属性だけ求めるコンポーネントになるということです。

1
2
3
4
5
6
7
8
9
interface Props {
count: number;
fooBar: string;
}
const mapStateToProps = (state: ReduxState) => ({
count: state.count
});
class Container extends React.Component<Props> { /* ... */ }
export default connect(mapStateToProps)(Container);
0
// in another file
1
<Container fooBar={"baz"} /> {/* no `count` attribute */}

ReturnType<T> を利用することで、何度も型のフィールドを書かなくても補完や型チェックを行えるようになります。(マッピング関数には大した処理を書かない、かつ、 Container Component が対象になることがほとんどという理由で、明示的に型を定義しなくてもいいと思っています。)

mapStateToProps + mapDispatchToProps
1
2
3
4
5
6
7
interface PropsToReceive { /* props */ } // if exists
const mapStateToProps = (state: ReduxState) => ({ /* ... */ });
const mapDispatchToProps = (dispatch: Dispatch) => ({ /* ... */ });

type Props = ReturnType<typeof mapStateToProps> & ReturnType<typeof mapDispatchToProps> & PropsToReceive;
class Container extends React.Component<Props> { /* ... */ }
export default connect(mapStateToProps, mapDispatchToProps)(Container); // => React.ComponentClass<PropsToReceive>
mapStateToProps + mapDispatchToProps + mergeProps
1
2
3
4
5
6
7
8
interface PropsToReceive { /* props */ } // if exists
const mapStateToProps = (state: ReduxState) => ({ /* ... */ });
const mapDispatchToProps = (dispatch: Dispatch) => ({ /* ... */ });
const mergeProps = (stateProps: ReturnType<typeof mapStateToProps>, dispatchProps: ReturnType<typeof mapDispatchProps, ownProps: PropsToReceive) => ({ /* ... */ });

type Props = ReturnType<typeof mergeProps>;
class Container extends React.Component<Props> { /* ... */ }
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(Container); // => React.ComponentClass<PropsToReceive>

(こんなようにするともっと面倒臭さを減らせるが、メンテナンス性からして現実的ではない。)

stoic?
1
2
3
4
5
6
7
8
9
type FromConnect<T> = T extends InferableComponentEnhancerWithProps<infer P, infer O> ? P : never; // in helper file

const con = connect(
(state: ReduxState) => ({ /* ... */ }),
(dispatch: Dispatch) => ({ /* ... */ }),
(stateProps, dispatchProps, ownProps: { /* props */ }) => ({ /* ... */ }) // if exists
);

export default con(class extends React.Component<FromConnect<typeof con>> { /* ... */ });

テスト

mapStateToPropsmapDispatchToPropsmergeProps をテストする場合には、それぞれに export をつけるか、 Redux Store のモックを接続して action が dispatch されたか確認してテストすれば良いと思います。ただし、複雑な処理では selector を使うなどすると、マッピングでは定型的なコードが多くなるように思うので、これらマッピング本体をテストする旨味は少ないかもしれませんね。

一方、コンポーネント自体のテストはとても重要です。 connect されるということは Container Component だと思いますが、子コンポーネントに影響するなど機能が豊富な場合も多いため、テストできると嬉しいはずです。
ストアを準備し connect 後のコンポーネントに対してテストするという結合テストのような格好を取ることもできますが、 connect される前の状態のコンポーネントも export し、それに対してテストするほうがバグの発見・予防に役立つのではないかと思います。( react-testing-library の公式サンプルでは前者になっているようですね…)

残念ながら、デコレータのほうの connect を使うと内部だけを取ってくることができないようなので、後者の戦略をとる場合は関数呼び出しの方の connect を無難に使ったほうがよさそうです。(型もダメだしテストもだめ。デコレータ版とは…)
そうした場合のテストはこんな感じになります。

unit test
1
2
3
4
5
6
import { Container } from "./Container"; // do not use default export for unit test!

test("renders string from `fooBar`", () => {
const { getByText } = render(<Container count={1} fooBar={"baz"} />);
expect(getByText("baz: 1")).not.toBeNull();
});

最後に

connect を使うときに型を何度も書かない方法と、 connect されたコンポーネントのテストの紹介しました。

React 16.7 からの Hooks ではこのあたりはどう変わるんですかね。丸ごとマップする Hooks か、個別にマップする Hooks か、という選択肢がありそうですが、後者のほうが(自分好みでないものの)しっくりきます。