【Redux不要】React ReduxチュートリアルのRedux部分をReact Hooksに書き換えてみた

JavaScript

「React Hooks APIを活用することでReduxが不要になる」という話を聞いたことがある方もいるかもしれません。

今回は、Reduxの機能をReactのみで実装する検証として、React ReduxのBasic Tutorialで紹介されているサンプルアプリケーション(Todoアプリ)をReact Hooks APIで書き換えたので紹介します。

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

今回作成するTodoアプリの概要

Todoアプリの仕様
  • 「Add Todo」ボタンでTodoを追加できる
  • Todoをクリックするとステータスのトグル操作(completed/incomplete)ができる
  • all/completed/incompleteのステータスでTodoをフィルタリングできる

モックを作成する

create-react-appを利用してアプリケーションのひな型を作成します。

### create-react-appを利用してアプリケーションを作成
$ npx create-react-app redux-todo-ts --template typescript
$ cd redux-todo-ts

### アプリケーションで必要になるnpmのインストール
$ yarn add classnames @types/classnames

### srcディレクトリのファイルを削除
$ cd redux-todo-app
$ rm -rf src/*

今回はCodeSandboxで公開されているTodoアプリのモックを利用しました。
当該モックはReduxを利用する前提であるため、React用にカスタマイズしました。

カスタマイズした点
  • TypeScript化
  • reduxディレクトリの廃止
  • Store(createStore)の削除
  • Reducerの結合(combineReducers)の削除
  • Selectorsの引数をStoreのStateから、コンテキストのStateに修正
  • Reducerで定義していたinitialStateの削除

モックのディレクトリ構成は以下の通りです。

.
├── README.md
├── package.json
├── src
│   ├── TodoApp.tsx
│   ├── actionTypes.ts
│   ├── actions.ts
│   ├── components
│   │   ├── AddTodo.tsx
│   │   ├── Todo.tsx
│   │   ├── TodoList.tsx
│   │   └── VisibilityFilters.tsx
│   ├── constants.ts
│   ├── index.tsx
│   ├── react-app-env.d.ts
│   ├── reducers
│   │   ├── todos.ts
│   │   └── visibilityFilter.ts
│   ├── selectors.ts
│   ├── styles.css
│   └── types.ts
├── tsconfig.json
└── yarn.lock

モックのソースコードはnishina555/react-redux-todoapp-reacthooks-mockで公開しています。実際に手を動かして確認する際に活用してください。

React Contextを利用してグローバルなデータを扱えるようにする

React ContextはReduxのようにグローバルなデータの定義と参照ができるReactのAPIです。
React Contextには「Reduxなしでグローバルなデータの定義ができる」「Props drillingと呼ばれる、いわゆるpropsのバケツリレー問題を解決できる」などのメリットがあります。

React Contextを利用する場合、以下のような実装をします。

React Contextで必要になる実装
  • React.createContextでコンテキスト(グローバルなデータ)を定義する
  • Context.Providerでコンテキストを渡す
  • Context.Consumerでコンテキストを受け取る

今回はReduxで実装していたグローバルなデータ管理をReact Contextで行います。
createContext()でコンテキストを作成し、作成されたコンテキストを管理するProviderコンポーネントを用意します。

現時点ではコンテキストは、いったん空で作成しています。

src/components/TodoProvider.tsx

import React from "react";

export const TodoContext = React.createContext("");

const TodoProvider: React.FC = ({ children }) => (
  <TodoContext.Provider value={""}>{children}</TodoContext.Provider>
);


export default TodoProvider;

他のコンポーネントでコンテキストを利用できるようにするため、Providerコンポーネントで全体を囲います。

src/TodoApp.tsx

import React from "react";
import AddTodo from "./components/AddTodo";
import TodoList from "./components/TodoList";
import VisibilityFilters from "./components/VisibilityFilters";
import "./styles.css";
+ import TodoProvider from "./components/TodoProvider";

export default function TodoApp() {
  return (
    <div className="todo-app">
      <h1>Todo List</h1>
+     <TodoProvider>
        <AddTodo />
        <TodoList />
        <VisibilityFilters />
+     </TodoProvider>
    </div>
  );
}

React ContextとuseReducer()を組み合わせ、StateとDispatch関数をグローバルで扱えるようにする

関数コンポーネントでStateを管理する場合useState()を利用します。useReducer()は、より複雑なStateを管理したい場合に利用するuseState()の代替となるHooksです。

useReducer()はReducer関数((state, action) => newStateという型をもつ関数)を受け取り、StateとDispatch関数のペアを返します。

Stateの初期値はuseReducer()の第2引数で指定します。(遅延初期化の場合は第2引数に初期値、第3引数に初期化関数を指定)

特に、React ContextとuseReducer()を組み合わせることで、StateとDispatch関数がグローバルで扱えます。
その結果、Reduxのように「各コンポーネントでActionを実行し、グローバルなStateを取得する」というアプリケーションが実現できます。

たとえば、各コンポーネントで「Actionの実行」「グローバルなStateの取得」をするには以下のようにします。

src/components/TodoProvider.tsx

import React, { useReducer } from "react";
import { TodoState } from "../types";
import { TodoActions } from "../actions";
import todoReducer from "../reducers/todos";

type TodoContextType = {
  todos: TodoState;
  dispatch: React.Dispatch<TodoActions>;
};

const initialState: TodoState = {
  allIds: [],
  byIds: {},
};

export const TodoContext = React.createContext({} as TodoContextType);

const TodoProvider: React.FC = ({ children }) => {
  const [todos, dispatch] = useReducer(todoReducer, initialState);
  return (
    <TodoContext.Provider value={{ todos, dispatch }}>
      {children}
    </TodoContext.Provider>
  );
};

export default TodoProvider;

useContext()を利用してコンポーネントからActionを実行(dispatch)できるようにする

useContext()はReact Contextのしくみを簡単に利用できるようにしたHooksです。
Contex.Providerによって渡されたコンテキストを受け取るContext.Consumerは、useContext()で代替できます。

たとえば、「Todoを追加する」というActionCreator(addTodo)を利用する場合は以下のようになります。
useContext(TodoContext)によってコンテキストで定義されているDispatch関数を受け取り、dispatch(addTodo)を実行しています。

src/components/AddTodo.tsx

import React, { useState, useContext } from "react";
import { TodoContext } from "./TodoProvider";
import { addTodo } from "../actions";

const AddTodo: React.FC = () => {
  const [input, setInput] = useState("");
  const { dispatch } = useContext(TodoContext);
  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;

以下のようにしてTodoContexttodosをログに表示した場合、Add Todoボタンを押したタイミングでtodosが変化していればOKです。

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

  // ActionのdispatchでStateが更新されるか確認
+ const todoState = useContext(TodoContext);
+ console.log(todoState.todos);
  (略)

useContext()を利用してコンポーネントでグローバルなStateを利用できるようにする

コンテキストで管理されているStateをuseContext()を利用して受け取り、コンポーネントに反映します。
const { todos } = useContext(TodoContext);todosというグローバルなStateがコンポーネントで扱えるようになります。

src/components/TodoList.tsx

import React, { useContext } from "react";
import Todo from "./Todo";
import { TodoContext } from "./TodoProvider";
import { getTodos } from "../selectors";

const TodoList = () => {
  const { todos } = useContext(TodoContext);
  const visibleTodos = getTodos(todos);
  return (
    <ul className="todo-list">
      {visibleTodos && visibleTodos.length
        ? visibleTodos.map((todo, index) => {
            return <Todo key={`todo-${todo.id}`} todo={todo} />;
          })
        : "No todos, yay!"}
    </ul>
  );
};

export default TodoList;

追加したTodoが画面に表示されればOKです。

Todoのcompleted/incompleteが切替られるようにする

前項と同様の手順で今度はTodoのステータスを切り替えるtoggleTodoをコンポーネントで利用できるようにします。

const { dispatch } = useContext(TodoContext);でDispatch関数を呼び出し、dispatch(toggleTodo(todo.id))でActionを実行しています。

src/components/Todo.tsx

import React, { useContext } from "react";
import cx from "classnames";
import { TodoItem } from "../selectors";
import { TodoContext } from "./TodoProvider";
import { toggleTodo } from "../actions";

type TodoProps = {
  todo: TodoItem;
};

const Todo: React.FC<TodoProps> = ({ todo }) => {
  const { dispatch } = useContext(TodoContext);
  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;

Todoをクリックし、ステータスが変更できればOKです。

ステータスに応じてTodoをフィルタリングできるようにする

Todoリストのフィルタ(VisibilityFilter)を管理するReact Contextを作成します。

src/components/VisibilityFilterProvider.tsx

import React, { useReducer } from "react";
import { VISIBILITY_FILTERS } from "../constants";
import visibilityFilterReducer from "../reducers/visibilityFilter";
import { VisibilityFilterTypes } from "../types";
import { TodoActions } from "../actions";

type VisibilityFilterContextType = {
  visibilityFilter: VisibilityFilterTypes;
  dispatch: React.Dispatch<TodoActions>;
};

const initialState: VisibilityFilterTypes = VISIBILITY_FILTERS.ALL;

export const VisibilityFilterContext = React.createContext(
  {} as VisibilityFilterContextType
);

const VisibilityFilterProvider: React.FC = ({ children }) => {
  const [visibilityFilter, dispatch] = useReducer(
    visibilityFilterReducer,
    initialState
  );
  return (
    <VisibilityFilterContext.Provider value={{ visibilityFilter, dispatch }}>
      {children}
    </VisibilityFilterContext.Provider>
  );
};

export default VisibilityFilterProvider;

他のコンポーネントでコンテキストを利用できるようにするため、Providerコンポーネントで囲います。

src/TodoApp.tsx

import React from "react";
import AddTodo from "./components/AddTodo";
import TodoList from "./components/TodoList";
import VisibilityFilters from "./components/VisibilityFilters";
import "./styles.css";
import TodoProvider from "./components/TodoProvider";
+ import VisibilityFilterProvider from "./components/VisibilityFilterProvider";

export default function TodoApp() {
  return (
    <div className="todo-app">
      <h1>Todo List</h1>
+     <VisibilityFilterProvider>
        <TodoProvider>
          <AddTodo />
          <TodoList />
          <VisibilityFilters />
        </TodoProvider>
+     </VisibilityFilterProvider>
    </div>
  );
}

次にTodoリストのフィルタ情報を更新するコンポーネントにuseContext()を追加し、コンテキストを扱えるようにします。

src/components/VisibilityFilters.tsx

import { useContext } from "react";
import cx from "classnames";
import { VISIBILITY_FILTERS } from "../constants";
import { setFilter } from "../actions";
import { VisibilityFilterContext } from "./VisibilityFilterProvider";

const VisibilityFilters = () => {
  const { visibilityFilter, dispatch } = useContext(VisibilityFilterContext);
  const activeFilter = 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;

フィルタリング結果を画面に反映させる

以下のようなSelectorメソッドを追加します。

src/selectors.ts

import { TodoState, VisibilityFilterTypes } from "./types";
import { VISIBILITY_FILTERS } from "./constants";


export const getTodosByVisibilityFilter = (
  todos: TodoState,
  visibilityFilter: VisibilityFilterTypes
): Array<TodoItem> => {
  const allTodos = getTodos(todos);
  switch (visibilityFilter) {
    case VISIBILITY_FILTERS.COMPLETED:
      return allTodos.filter((todo) => todo.completed);
    case VISIBILITY_FILTERS.INCOMPLETE:
      return allTodos.filter((todo) => !todo.completed);
    case VISIBILITY_FILTERS.ALL:
    default:
      return allTodos;
  }
};

TodoList.tsxを以下のように修正します。

src/components/TodoList.tsx

- import { getTodos } from "../selectors";
+ import { getTodosByVisibilityFilter } from "../selectors";
+ import { VisibilityFilterContext } from "./VisibilityFilterProvider";

  const TodoList = () => {
    const { todos } = useContext(TodoContext);
-   const visibleTodos = getTodos(todos);
+   const { visibilityFilter } = useContext(VisibilityFilterContext);
+   const visibleTodos = getTodosByVisibilityFilter(todos, visibilityFilter);
    return (
      <ul className="todo-list">
        {visibleTodos && visibleTodos.length
        (略)

フィルタのリンク(all/completed/incomplete)をクリックし、画面のTodoがフィルタリングされていればOKです。

まとめ

今回のまとめ
  • React Contextを利用することでグローバルなデータ(コンテキスト)が利用できる
  • useReducer()はuseState()の代替となるReact Hooks。複雑なState管理をするときに利用する
  • React ContextとuseReducer()を組み合わせることで、StateとDispatch関数がグローバルで扱える
  • useContext()を利用することでコンテキストを取得できる

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