React Reduxのチュートリアル(Todoアプリ)をReact Redux Hooksで書き換えてみた

JavaScript

React Redux hooksはconnect()の代わりとなるAPIです。
React Redux hooks APIを利用することでReduxのStateやActionの実行(dispatch)がconnect()なしで行えます。

React Redux hooks APIには以下の3つがあります。

React Redux hooks API
  • useSelector()
  • useDispatch()
  • useStore()

今回はReact ReduxのBasic Tutorialで紹介されているサンプルアプリケーション(Todoアプリ)をReact Redux hooks APIで書き換えたので紹介します。
コードはTypeScriptで記述しています。React ReduxのチュートリアルをTypeScriptに書き換える手順は『React Reduxのチュートリアル(Todoアプリ)をTypeScriptで書き換えてみた』を参考にしてください。

React Reduxのチュートリアル(Todoアプリ)をTypeScriptで書き換えてみた

react-reduxは7.1.16、reactは17.0.1、typescriptは3.8.3を利用しています。

下準備: AddTodoをクラスコンポーネントから関数コンポーネントにする

hooks APIを使う下準備としてAddTodo.tsxをクラスコンポーネントから関数コンポーネントに書き換えます。

クラスコンポーネントから関数コンポーネントに書き換える際の変更点は以下の通りです。

クラスコンポーネントから関数コンポーネントに書き換え
  • constructorを削除
  • thisを削除
  • AddTodoStateを削除し、代わりにuseState()を追加
  • 型定義をReact.ComponentからReact.FCにする

src/components/AddTodo.tsx

import React, { useState } from "react";
import { connect } from "react-redux";
import { addTodo } from "../redux/actions";
import { TodoActions } from "../redux/actions";

type AddTodoProps = {
  addTodo: (input: string) => TodoActions;
};

const AddTodo: React.FC<AddTodoProps> = ({ addTodo }) => {
  const [input, setInput] = useState("");

  const updateInput = (input: string) => {
    setInput(input);
  };

  const handleAddTodo = () => {
    addTodo(input);
    setInput("");
  };

  return (
    <div>
      <input onChange={(e) => updateInput(e.target.value)} value={input} />
      <button className="add-todo" onClick={handleAddTodo}>
        Add Todo
      </button>
    </div>
  );
};

export default connect(null, { addTodo })(AddTodo);

Reactチュートリアルを関数コンポーネントとTypeScriptで書き換えてみた

mapDispatchToPropsの代わりにuseDispatch()を利用する

useDispatch()connect()でいうところのmapDispatchToPropsにあたります。

まずはDispatchの型を定義します。
Redux Toolkitを利用している場合、Dispatchの型は以下のように取得します。1

src/redux/store.ts

import { createStore } from "redux";
import rootReducer from "./reducers";

const store = createStore(
  rootReducer,
  (window as any).__REDUX_DEVTOOLS_EXTENSION__ &&
    (window as any).__REDUX_DEVTOOLS_EXTENSION__()
);

export default store;

export type AppDispatch = typeof store.dispatch

たとえばAddTodo.tsxの場合、useDispatch()を利用してaddTodoを実行するには以下のようになります。
const dispatch: AppDispatch = useDispatch();dispatchを利用できるようにし、dispatch(addTodo(input));でActionをdispatchしています。

src/components/AddTodo.tsx

import React, { useState } from "react";
import { addTodo } from "../redux/actions";
import { useDispatch } from "react-redux";
import { AppDispatch } from "../redux/store";

const AddTodo: React.FC = () => {
  const dispatch: AppDispatch = useDispatch();
  const [input, setInput] = useState("");

  const updateInput = (input: string) => {
    setInput(input);
  };

  const handleAddTodo = () => {
    dispatch(addTodo(input));
    setInput("");
  };

  return (
    <div>
      <input onChange={(e) => updateInput(e.target.value)} value={input} />
      <button className="add-todo" onClick={handleAddTodo}>
        Add Todo
      </button>
    </div>
  );
};

export default AddTodo;

Actionを実行しているそのほかのコンポーネントの修正は以下の通りです。

src/components/Todo.tsx

import React from "react";
import cx from "classnames";
import { toggleTodo } from "../redux/actions";
import { TodoItem } from "../redux/selectors";
import { useDispatch } from "react-redux";
import { AppDispatch } from "../redux/store";

type TodoProps = {
  todo: TodoItem;
};

const Todo: React.FC<TodoProps> = ({ todo }) => {
  const dispatch: AppDispatch = useDispatch();
  return (
    <li className="todo-item" onClick={() => dispatch(toggleTodo(todo.id))}>
      {todo && todo.completed ? "👌" : "👋"}{" "}
      <span
        className={cx(
          "todo-item__text",
          todo && todo.completed && "todo-item__text--completed"
        )}
      >
        {todo.content}
      </span>
    </li>
  );
};

export default Todo;

src/components/VisibilityFilters.tsx

import React from "react";
import cx from "classnames";
import { VISIBILITY_FILTERS } from "../constants";
import { connect } from "react-redux";
import { setFilter } from "../redux/actions";
import { State, VisibilityFilterTypes } from "../redux/types";
import { AppDispatch } from "../redux/store";
import { useDispatch } from "react-redux";

type VisibilityFiltersProps = {
  activeFilter: VisibilityFilterTypes;
};

const VisibilityFilters: React.FC<VisibilityFiltersProps> = ({
  activeFilter,
}) => {
  const dispatch: AppDispatch = useDispatch();
  return (
    <div className="visibility-filters">
      {(Object.keys(VISIBILITY_FILTERS) as Array<
        keyof typeof VISIBILITY_FILTERS
      >).map((filterKey) => {
        const currentFilter = VISIBILITY_FILTERS[filterKey];
        return (
          <span
            key={`visibility-filter-${currentFilter}`}
            className={cx(
              "filter",
              currentFilter === activeFilter && "filter--active"
            )}
            onClick={() => dispatch(setFilter(currentFilter))}
          >
            {currentFilter}
          </span>
        );
      })}
    </div>
  );
};

const mapStateToProps = (state: State) => {
  return { activeFilter: state.visibilityFilter };
};

export default connect(mapStateToProps)(VisibilityFilters);

mapStateToPropsの代わりにuseSelector()を利用する

useSelector()connect()でいうところのmapStateToPropsにあたります。

useSelector()に書き換えると以下のようになります。
mapStateToPropsの代わりにuseSelector()を利用してtodosを取得するようにしています。

src/components/TodoList.tsx

import React from "react";
import Todo from "./Todo";
import { State } from "../redux/types";
import { getTodosByVisibilityFilter, TodoItem } from "../redux/selectors";
import { useSelector } from "react-redux";

const TodoList: React.FC = () => {
  const todos: Array<TodoItem> = useSelector((state: State) => {
    const { visibilityFilter } = state;
    return getTodosByVisibilityFilter(state, visibilityFilter);
  });
  return (
    <ul className="todo-list">
      {todos && todos.length
        ? todos.map((todo: TodoItem, index: number) => {
            return <Todo key={`todo-${todo.id}`} todo={todo} />;
          })
        : "No todos, yay!"}
    </ul>
  );
};

export default TodoList;

同様の方法でVisibilityFiltersも修正します。

src/components/VisibilityFilters.tsx

import React from "react";
import cx from "classnames";
import { VISIBILITY_FILTERS } from "../constants";
import { setFilter } from "../redux/actions";
import { State, VisibilityFilterTypes } from "../redux/types";
import { AppDispatch } from "../redux/store";
import { useDispatch, useSelector } from "react-redux";

const VisibilityFilters: React.FC = () => {
  const dispatch: AppDispatch = useDispatch();
  const activeFilter: VisibilityFilterTypes = useSelector(
    (state: State) => state.visibilityFilter
  );
  return (
    <div className="visibility-filters">
      {(Object.keys(VISIBILITY_FILTERS) as Array<
        keyof typeof VISIBILITY_FILTERS
      >).map((filterKey) => {
        const currentFilter = VISIBILITY_FILTERS[filterKey];
        return (
          <span
            key={`visibility-filter-${currentFilter}`}
            className={cx(
              "filter",
              currentFilter === activeFilter && "filter--active"
            )}
            onClick={() => dispatch(setFilter(currentFilter))}
          >
            {currentFilter}
          </span>
        );
      })}
    </div>
  );
};

export default VisibilityFilters;

参考: useSelector()を利用する際の注意点

useSelector()ではconnect()と違い、キャッシュの利用可否の判定に===(厳密な比較)が使われます。2
ですので、useSelector()でオブジェクトを取得する場合はパフォーマンス面の注意が必要です。
なぜならオブジェクトは===(厳密な比較)において、プロパティのスカラ値が等しい場合(= オブジェクトの値が同じ場合)も、参照先が異なればfalseになるためです。
その結果、オブジェクトの見かけ上の値は変わっていないのにもかかわらずキャッシュが利用されないというケースが発生します。

useSelector()でオブジェクトを取得する場合、キャッシュを活用するには浅い比較(shallow equal)を利用する必要があります。
浅い比較であれば、別インスタンスであってもプロパティのスカラ値が一致していればオブジェクトの比較はtrueになります。(= キャッシュが有効になる。)

import { shallowEqual, useSelector } from 'react-redux'

// useSelector()で、'==='の代わりに浅い比較を利用する方法
const selectedData = useSelector(selectorReturningObject, shallowEqual)

===と浅い比較の違いは以下の通りです。

import { shallowEqual } from 'react-redux'

const a = { value: "hoge" }
const b = { value: "hoge" }

// === の場合はfalseになる
// つまり、オブジェクトのプロパティの値が同じでもuseSelector()ではキャッシュを利用しない
console.log(a === b)
// => false

// 浅い比較において、a, b はtrueになる
console.log(shallowEqual(a, b))
// => true

JavaScriptの浅い比較・浅い(深い)コピーの挙動まとめ

【React】JavaScriptのイミュータビリティを理解して正しくState更新する

補足: useStore()について

今回紹介していないuseStore()について補足説明をします。

useStore()<Provider store={store}>で渡しているStoreの情報(State)をコンポーネントで直接参照できるhooks APIです。
しかしuseStore()でStateの情報を取得した場合、Stateの更新に対してコンポーネントが連動しません。そのため、Stateを利用する場合は原則useSelector()を利用するのが好ましいです。
useStore()は「Reducerを置き換える場合」など、Storeの情報を直接参照しないといけないようなケースでのみ利用します。3

以上の理由から今回の記事ではuseStore()は紹介しませんでした。

最終的なアプリケーション

まとめ

まとめ
  • React Redux hooks APIにはuseSelector(), useDispatch(), useStore()がある
  • React Redux hooks APIを利用することでconnect()が不要になる
  • useSelector()はmapStateToPropsの代わりになるもの
  • useSelector()でオブジェクトを取得する際はパフォーマンス面に注意する
  • useDispatch()はmapDispatchToPropsの代わりになるもの
  • Stateの情報を取得するときは原則useStore()ではなくuseSelector()を利用する

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!