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


※試行錯誤の最中で下記は解説ではなくメモ書きに近いです

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 時代っぽい?

通常の 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>
  );
};

// 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).toHaveBeenCalledWith({ type: "increment" });
expect(mockDispatch).toHaveBeenCalledTimes(1);

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

上記記事のコメント欄で react-redux を mock にする方法が紹介されておりその方法を参考にしました。

コンポネント内で dispatch する方の利点は、親子関係に縛られずコンポネントの機能と UI を紐付けて使いまわせることです。react-redux の Provider 配下なら、このボタンコンポネントを配置すればどこからでも increment できます。

props で渡す方は UI が使い回しやすく、一部だけ機能を変更する時に便利です。例えば、dispatch する内容が増えたときや配置場所によって処理を変えるときに考えることが減ります。

両方の合わせ技のような、props に initial state を渡す方法も面白いです。

import * as React from "react";
import { useDispatch } from "react-redux";
import { CounterAction } from "../store";

interface IProps {
  usePropsDispatch: any;
}
const CounterIncrementBtn: React.FC<IProps> = ({
  usePropsDispatch = useDispatch
}) => {
  const dispatch = usePropsDispatch();

  return (
    <button
      onClick={() => dispatch({ type: CounterAction.INCREMENT })}
      data-testid="increment-btn"
    >
      +
    </button>
  );
};

test("click increment btn show counter value +1", () => {
  const mockDispatch = jest.fn();
  const { getByTestId } = render(
    <CounterIncrementBtn usePropsDispatch={mockDispatch} />
  );
  expect(getByTestId("increment-btn")).toHaveTextContent("+");
  expect(mockDispatch).toHaveBeenCalledTimes(1);
});

色々なコンポネントで Redux を意識するよりは、以前の FC 的な手軽さを持って hooks を振り回せるのが理想です。テストファイル内で Store 作成してテストするのが依然として正解かもしれませんが、しっくりくるまでは試行錯誤続けようと思います。

フロントエンドとテストと設計

バックエンドだと Clean Architecture や DDD といった開発手法が(実際に取り入れられているのかは不明ですが)流行していて、オブジェクト指向だけでなく interface や DI といったワードをよくみるようになりました。

フロントエンドも TypeScript あるのでそういった流行に乗れる気はするものの、React の hooks は state とロジックを合わせて切り出せるとはいえ React コンポネント専用という制約があります。そのため、React のプロジェクトで domain、usecase、repository フォルダが切られていたとしても、どれも interface というよりは React コンポネントに依存していて、再利用性を高めるという点で考えてもあまり有用ではないのかなと思います。後ろにいるのは常に React コンポネントなら、あえて interface でつないでもファイル数が増えるだけのメリットがないのではと。

テストを書くのも将来の拡張時に既存の機能を壊す不安を消せるとか、勇気を持って拡張できるという面が大きいと思います。なので、テストが書けないから新しい手法を取り入れなかったり、テストが書けるようにコンポネント側のコードに大きく影響が出たり、機能追加が遅れるのは本末転倒な気もしていて、バランス感覚が難しいと感じます。

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

React hooks は React の functional component 内で使うことができます。ロジックを抽出してカスタム hooks を作り、コンポネント間で使い回したりもできます。これが便利で、Context と合わせて Redux ライクなことも React 単独でできるようになりました。また、class と this がコードから消せます。

context + hooks は Redux と違い middleware がありません。非同期の世界を Redux レベルで扱うベストプラクティスがまだないというのが現状でしょうか。Redux には副作用は middleware に切り出すというルールがありますが、React にはないのでそのあたりでも扱いが変わると思います。

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

あるいは、middleware 内でデータ fetch して Redux ストアの更新する時、ついでに context も更新したい。。。という時にどのように Redux とやりくりするのか良い方法が浮かばずにいます。そもそも Redux と context 両方使おうというのが、Redux の概念に反している気もしますが、それも制限すると React 自体 の方向性と外れてくるのではとも感じます。

保守の影

最近、Angular と RxJS の学習コストに難を示す記事がバズっていた時に色々感じたのですが、React は一見自由度はありますがライブラリ選定は難しくて、無難に React + Redux の構成をとりあえず正解としておく!という状況が多いのではと思います。次のプロジェクト、React hooks + context でガリガリいくぞー!というのは元気な React 使いが集まっていないと厳しい面があるのではと。

facebook の人が、自分の書いた数年前の Redux コードよくわからないみたいなツイートしたりと、Redux は便利ですが依存しすぎるなというメッセージをひしひしと感じることが増えました。React は自由なので Redux を使うのもまた自由なのですが、じゃあ hooks 時代における Redux の立ち位置は今までと全く同じなのか?という点が悩ましい。

Redux 以外でも現状 Experimental な Suspense が賑わいを見せており、どんどん進化していく React には楽しさと保守の怖さが見え隠れするように感じます。常にフロントエンドもバックエンドも改善できる環境ならそれは凄いパワーですが、1〜2 年のスパンでも放置されてしまえばどうなるのやらと。

自由に色々と試せることに前向きな気持ちがありつつ、数年後にぶつかるだろう当時ちょっとだけ流行していたが今は流行していない技術採用時に起きる、保守の闇の部分に不安を覚えつつ。正解を求めすぎずに多種多様な素振りが現状必要なのかなと思います。