React hooks対応のReact ReduxはどこでuseSelector、useDispatchするのか

公開日:2019 M11 7最終更新日: 2019 M11 11

※試行錯誤の最中で下記は解説ではなくメモ書きに近いです。良いアイデア、一般的な解法があるなら教えてください

React で Redux を使用するときは、Redux store と connect するための component を使うのが一般的でした。よく見る mapStateToProps と mapDispatchToProps がある風景です。

import { connect } from "react-redux";
import Counter from "../components/Counter";

const mapStateToProps = (state, ownProps) => {
  return {
    // 省略
  };
};

const mapDispatchToProps = (dispatch, ownProps) => {
  return {
    // 省略
  };
};

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Counter);

React と Redux を React Redux で接続して使います。store とか action とか reducer とか色々あって、非同期に middleware!redux-thunk!redux-saga!というあたりで一回凹むのが React。

React Redux が hooks 対応した後の世界では、上記のような connect 用 component が不要になり、React Redux が提供する hooks からアクセス可能になりました。これによりどのコンポネントからでも気軽に Redux 管理の State にアクセスでき、dispatch もできます。

import React from "react";
import { useSelector, useDispatch } from "react-redux";

const Counter = () => {
  const dispatch = useDispatch();
  const count = useSelector(state => state.count);

  const increment = () => {
    dispatch({ type: "increment" });
  };

  return (
    <div>
      <button onClick={increment}>+</button>
      <div>{count}</div>
    </div>
  );
};

どこで Redux Store にアクセスするか

connect 時代と違い、hooks 時代はとにかく気楽に Redux と繋がることができます。例えば Counter コンポネントを、カウント表示とカウント増加ボタンで分割します。

const IncrementBtn = () => {
  return <button onClick={increment}>+</button>;
};

const Count = () => {
  return <div>{count}</div>;
};

const Counter = () => {
  return (
    <div>
      <IncrementBtn />
      <Count />
    </div>
  );
};

従来なら Counter を Redux store と connect して、count と increment をそれぞれのコンポネントに props すると思います。IncrementBtn と Count は stateless functional component として使います。

const IncrementBtn = ({ increment }) => {
  return <button onClick={increment}>+</button>;
};

const Count = ({ count }) => {
  return <div>{count}</div>;
};

hooks 対応版だと気軽にアクセスできる分、props で流さず

const IncrementBtn = () => {
  const dispatch = useDispatch();
  const increment = () => {
    dispatch({ type: "increment" });
  };
  return <button onClick={increment}>+</button>;
};

const Count = () => {
  const count = useSelector(state => state.count);
  return <div>{count}</div>;
};

それぞれの functional component で Redux の state を扱い dispatch する。こっちの方が React hooks 時代っぽい?といくつか試して実装してみて問題にぶつかりました。

こちらのパターンはコンポネントが Redux と紐づくためテストが難しくなります。通常の functional component なら

const IncrementBtn = ({ increment }) => {
  return (
    <button onClick={increment} data-testid="increment-btn">
      +
    </button>
  );
};

test("クリックするとpropsで渡した関数が使用される", () => {
  const increment = jest.fn();
  const { getByTestId } = render(<IncrementBtn increment={increment} />);

  expect(getByTestId("increment-btn")).toHaveTextContent("+");

  fireEvent.click(getByTestId("increment-btn"));
  expect(increment).toHaveBeenCalledTimes(1);
});

testing-library 使用でこんな感じ。渡している increment の実装は IncrementBtn には関係がなく、渡した関数をクリックして使用されているかをテストすれば大丈夫のはずです。

次に props ではなく自コンポネント内で dispatch を import して使う場合です。

const IncrementBtn = () => {
  const dispatch = useDispatch();
  const increment = () => {
    dispatch({ type: "increment" });
  };
  return (
    <button onClick={increment} data-testid="increment-btn">
      +
    </button>
  );
};

function renderWithRedux() {
  // 省略
}

test("クリックするとactionをdispatchする", () => {
  const { getByTestId } = renderWithRedux(<IncrementBtn />);

  expect(getByTestId("increment-btn")).toHaveTextContent("+");

  fireEvent.click(getByTestId("increment-btn"));

  // 正しい振る舞いは。。。
});

期待するのは正しく action が dispatch されているかでテストが途端に難しくなりました。Provider で store と接続する必要もあります。親コンポネントの Counter は、一見 Redux と connect せずにすんでいますが、子のコンポネントが Redux に依存している時点で親も Redux 依存しています。

それなら、親のコンポネントは useDispatch と useSelector で Redux に依存させ、子のコンポネントはそれらを props で受け取るようにすれば Redux 依存のコンポネントの数は減り、テスト難易度を下げながら進んだ方が良いのではと考えました。つまり、mapStateToProps とかなくなったとしても、親要素から props していく方向性は持続した方が良いのではと。

ただ、テストを軸に考えてコンポネンとの実装が変わるのは本末転倒に思えるのと、Redux ではなく単純にカスタム hooks とテストの関係で考えるなら・・・うーんというところです。

// あるいはreact-reduxをmockにする
const mockDispatch = jest.fn();
jest.mock("react-redux", () => ({
  useDispatch: () => mockDispatch
}));

const { getByTestId } = render(<IncrementBtn />);
expect(getByTestId("increment-btn")).toHaveTextContent("+");

fireEvent.click(getByTestId("increment-btn"));
expect(mockDispatch).toHaveBeenCalledTimes(1);

https://qiita.com/isy/items/0b40b50fad21a8e6c863

上記記事のコメント欄では react-redux を mock にする方法が紹介されていました。この方法がよさそうではあります。

hooks を扱えるのは React コンポネントのみ

React hooks は React の functional component 内で使うことができます。ロジックを抽出してカスタム hooks を作り、コンポネント間で使い回したりもできます。これが便利で、Context と合わせて Redux ライクなことも React 単独でできるようになりました。context + hooks は Redux と違い、middleware がありません。非同期の世界を Redux レベルではどうこうしにくいのが現状でしょうか。

一方、Redux は React Redux で React コンポネントと繋がりはしますが React ではありません。なので、middleware からはカスタム hooks にアクセスできず、必要なデータは payload でデータを受け渡してから何かするということになります。これがちょっと手間です。

あるいは、middleware 内でデータ fetch して Redux ストアの更新する時、ついでに context も更新したい。。。という時にどのように Redux とやりくりするのか良い方法が浮かばずにいます。そもそも Redux と context 両方使おうというのが、Redux の概念に反している気もしますが、そうしないと、React の方向性と外れてくるような気もするので、この辺りベストプラクティスが欲しいところです。

しばらく試行錯誤が続きそうです


ReactJavaScript
hidekazoo
作者: hidekazooTwitter
JavaScriptやPHPなどプログラム関係やDockerといった開発環境など、ITエンジニアとして興味あることを紹介しているサイトです