ReactのReduxの基礎は理解できているような気がするけど少し実践的なコードで理解を深めたいという人向けにReactのReduxを利用したTodoリストのアプリを作成します。useState, useSelector, useDispatchなどのReact Hooksを利用して作成していきます。connectやmapStateToPropsは利用しません。

作成するTodoリストのアプリではTodoリストの追加、削除、Todoリストのステータス変更(未完了→完了)の機能を持ちます。

環境の準備

Todoリストのアプリケーションを構築する前にReactプロジェクトの作成を行います。


 % npx create-react-app react-todo-list

作成したプロジェクトディレクトリに移動して、npx startコマンドを実行します。Reactが起動するか確認してください。


 % cd redux-learn
 % npm start
Reactの初期ページ
React初期ページ

本文書で利用しないsrcディレクトリの下にあるファイル(logo.svg, App.test.js, serviceWorker.js, setUpsTests.js)を削除しindex.jsファイルを更新します。


import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";


ReactDOM.render(
  >React.StrictMode>
    >App />
  >/React.StrictMode>,
  document.getElementById("root")
);
ファイルの削除やファイルの更新はReduxを利用するための必須な作業ではありません。読者の人がどの情報がReduxに影響を与えるか迷わせないため関連のない情報を可能なかぎり削除することを目的にしています。

index.cssファイルではデフォルトで設定されているmarginやpaddingに0を設定します。


* {
  margin: 0;
  padding: 0;
}

App.cssでAppクラスのみ残し下記のように更新します。


.App {
  text-align: center;
}

App.jsではh1タグでアプリケーションのタイトルを設定ます。


import React from "react";
import "./App.css";

function App() {
  return (
    <div className="App">
      <h1>ReduxでTodoリスト作成</h1>
    </div>
  );
}

export default App;

ブラウザで確認するとApp.jsに記述した”ReduxでTodoリスト作成”が中央に表示されます。

タイトルの表示
タイトルの表示

Reactでの準備は完了したのでreduxとreact-reduxをインストールします。

reduxとreact-reduxのインストール

ReactでReduxを利用するためにはreduxとreact-reduxをインストールする必要があります。npmコマンドでインストールを行います。


 % npx create-react-app react-todo-list

Storeの作成

Todoリストを保存するためのStoreの作成を行います。srcディレクトリの下にstoreディレクトリを作成、その下にindex.jsファイルを作成します。


import { createStore } from "redux";

const store = createStore(reducer);

export default store;

createStoreの引数にはreducerが必須なのでreducerを作成します。

reducerの作成

Todoリストはnameとcompleteプロパティを持ち、初期値には2つのTodoリストを保持させます。nameプロパティはTodoの名前、completeプロパティはそのTodoが完了しているか未完了かを識別するために利用します。reducerのactionはまだ何も設定を行っていません。


import { createStore } from "redux";

const initialState = {
  lists: [
    {
      name: "ブログを確認",
      complete: false,
    },
    {
      name: "メールの返信",
      complete: false,
    },
  ],
};

const reducer = (state = initialState, action) => {
  return state;
};

const store = createStore(reducer);

export default store;

Providerの設定

srcディレクトリのindex.jsファイルを開いてProviderとstoreの設定を行います。Providerの設定が完了するとReactのコンポーネントからReduxのStoreにアクセスする準備は完了です。


import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import { Provider } from "react-redux";
import store from "./store/index";

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

Todoリストへのアクセスと表示

Reactコンポーネントからlistsにアクセスするための準備が整ったのでStoreに保管されているlistsをブラウザに表示します。表示はApp.jsのコンポーネントで行います。

Storeに保管されているlistsデータにはuseSelectorを利用してアクセスします。コンポーネントの先頭ではimportも忘れずに行います。


import { useSelector } from "react-redux";
//略
const lists = useSelector((state) => state.lists);

useSelectorを利用してlistsの値を取得できたのでmap関数で展開し、ブラウザ上に表示させます。


import React from "react";
import "./App.css";
import { useSelector } from "react-redux";

function App() {
  const lists = useSelector((state) => state.lists);
  return (
    <div className="App">
      <h1>ReduxでTodoリスト作成</h1>
      <h2>Todoリスト</h2>
      <ul>
        {lists.map((list, index) => (
          <li key={index}>{list.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default App;

ブラウザで確認すると初期値で設定した2つのTodoリストが表示されます。ReduxのStoreに保管されたデータへのアクセスと表示をここまでの処理で実現することができました。

ToDoリストを表示
ToDoリストを表示

完了リストと未完了リスト

Todoリストを表示することができました。各リストにはcompleteプロパティがあり、リストが完了しているか完了していないかを識別することができるのでTodoリストを2つのリストに分けます。

filter関数を利用することで完了していないリストを取り出し、取り出したリストでmapを実行してリストを展開しています。completeがfalseであるリストのみを表示させることができます。


{lists
  .filter((list) => list.complete === false)
  .map((list, index) => (
    <div key={index}>{list.name}</div>
  ))}

完了のリストはlist.complete === trueに設定します。completeがtrueのリストのみ表示させます。


{lists
  .filter((list) => list.complete === true)
  .map((list, index) => (
    <div key={index}>{list.name}</div>
  ))}

完了と未完了のコードをApp.jsの中に組み込みます。


import React from "react";
import "./App.css";
import { useSelector } from "react-redux";

function App() {
  const lists = useSelector((state) => state.lists);
  return (
    <div className="App">
      <h1>ReduxでTodoリスト作成</h1>
      <h2>未完了のTodoリスト</h2>
      <ul>
        {lists
          .filter((list) => list.complete === false)
          .map((list, index) => (
            <div key={index}>{list.name}</div>
          ))}
      </ul>
      <h2>完了したTodoリスト</h2>
      <ul>
        {lists
          .filter((list) => list.complete === true)
          .map((list, index) => (
            <div key={index}>{list.name}</div>
          ))}
      </ul>
    </div>
  );
}

export default App;

初期値のcompleteの値がすべてfalseなので未完了のTodoリストに2つのリストが表示されます。

リストを2つに分ける
リストを2つに分ける

reducerのACTIONの設定

ここからはreducerを利用してStoreに保管されているlistsに対して変更を加えています。変更を加えるためにはACTIONとreducerでのACTIONの処理の実装が必要となります。

リストの移動(未完了→完了)

未完了のTodoリストが完了したら完了リストに移動できるように完了ボタンを追加します。onClickイベントを設定して、ボタンをクリックするとdoneListメソッドを実行し、どのリストか識別できるようにlist.nameを引数とします。


 <button onClick={() => doneList(list.name)}>完了</button>

Todoリストの横にボタン要素を追加します。


  <ul>
    {lists
      .filter((list) => list.complete === false)
      .map((list, index) => (
        <div key={index}>
          {list.name}
          <button onClick={() => doneList(list.name)}>完了</button>
        </div>
      ))}
  </ul>

Storeに保管されているデータを変更する必要があるのでdispatch関数を利用してreducedrにACTIONを通知します。dispatch関数の引数にはACTIONを設定する必要があります。reducer側でACTIONの設定はまだ行っていませんが、ACTIONのtypeを”DONE_LIST”としてpayloadにはdoneListメソッドから渡されるnameを設定します。nameはリストを特定する際に利用します。


import { useSelector, useDispatch } from "react-redux";
//略
const dispatch = useDispatch();
const doneList = (name) => {
  dispatch({ type: "DONE_LIST", payload: name });
};

reducerで”DONE_LIST”のACTIONを設定します。switchを使ってaction.typeの値により変更処理を分岐させています。今回のTodoリストのアプリでは3つのACTIONの処理をreducerに追加することになります。

”DONE_LIST”の処理では現在のstate.listsをmap関数で展開し、payloadのnameと異なるリストはそのまま戻し、payloadのnameと一致するリスト名を持つリストを取り出してcompleteプロパティの値をtrueに設定して新しいstateとして戻しています。reducerではstateそのものを変更するのではなく現在のstateを元に新しいstateを作成する必要があるため下記のようなコードとなります。下記のコードではその意味がわかりにくかもしれませんが、最後に説明を行う”ADD_LIST”の処理のコードを確認することでその意味を理解することができます。


const reducer = (state = initialState, action) =< {
  switch (action.type) {
    case "DONE_LIST":
      return {
        lists: state.lists.map((list) =< {
          if (list.name !== action.payload) return list;
          return {
            ...list,
            complete: true,
          };
        }),
      };
    default:
      return state;
  }
};

ブラウザで確認すると完了ボタンがついており、完了ボタンをクリックすると未完了リストから完了リストにリストが移動します。

完了ボタンを押す前
完了ボタンを押す前

ブログの確認の右にある完了ボタンを押すと完了した Todoリストに移動します。

リストの移動
リストの移動

Storeに保管されているデータの変更の流れを再度確認します。完了ボタンを押すとdoneListメソッドが実行され、dispatch関数でreducerにACTIONの”DONE_LIST”を実行するように通知を行います。”DONE_LIST”のACTIONを受け取ったreducerがpayloadを利用してTodoリストの特定のリスト情報を変更します。変更はリアルタイムでブラウザ上の描写に反映されます。

ここまで設定が理解できれば自分の力でTodoリスのアプリケーションの機能を追加したり変更したりすることが可能です。理由は先ほどと同じ方法で各機能のメソッドとreducer内でのACTIONの処理を追加していくだけだからです。

リストの削除

新たに削除ボタンを追加して新たにdeleteListメソッドを作成します。


<button onClick={() => deleteList(list.name)}>削除</button>

  <ul>
    {lists
      .filter((list) => list.complete === false)
      .map((list, index) => (
        <div key={index}>
          {list.name}
          <button onClick={() => doneList(list.name)}>完了</button>
          <button onClick={() => deleteList(list.name)}>削除</button>
        </div>
      ))}
  </ul>

deleteListメソッド内ではdispatch関数の引数にtypeがDELETE_LISTを持つACTIONを設定します。削除するリストを識別するためにpayloadにはnameを設定します。


  const deleteList = (name) => {
    dispatch({ type: "DELETE_LIST", payload: name });
  };

ACTIONのtypeがDELETE_LISTなのでreducerにDELETE_LISTの処理を追加します。

state.listsからpayloadで受け取ったnameを持つリストを削除するのではなく、現在のstate.listsの中からfilterを使ってpayloadのnameを持たないリストのみを取り出して新しいstateにしています。ここでも既存のstateを更新するのではなく既存のstateから新しいstateを作成しています。


case "DELETE_LIST":
  return {
    lists: state.lists.filter((list) => list.name !== action.payload),
  };

ブラウザで確認するとリストの横に完了ボタンと削除ボタンが表示され、追加した削除ボタンをクリックするとリストはブラウザ上から消えます。

削除ボタン表示
削除ボタン表示

削除ボタンをクリックするとリストから削除されます。

削除ボタンをクリックするとリストから削除
削除ボタンをクリックするとリストから削除

リストの追加

最後にリスト追加の機能を追加します。React Hooksの一つであるuseStateを利用します。まずuseStateを利用するためimportを行います。useStateを利用することでFunctionコンポーネントにデータ(変数)を保持することができます。


import React, { useState } from "react";

ここではTodoリストのプロパティであるnameとcompleteをuseStateを使って変数として宣言し入力フォームで入力した値を保持します。name, completeが変数でsetName、senCompleteを利用して変数を変更します。useStateの引数には初期値を設定することができ、nameの初期値は””、completeは入力時にはTodoは未完了なのでfalseに設定しています。


  const [name, setName] = useState("");
  const [complete, setComplete] = useState(false);

input要素を追加しvalueに変数nameを設定し、入力を行うとonChangeイベントによりinputTextメソッドが実行されます。


<input type="text" value={name} onChange={inputText} />

inputTextメソッドではinput要素で入力された値をe.target.valueから取得しsetNameの引数に設定しnameに入力値を反映されます。


 const inputText = (e) => {
    setName(e.target.value);
  };

nameの値をTodoリストに追加するための機能を実装するため新たに”追加”ボタンを追加します。”追加”ボタンをクリックするとclickイベントによりaddListメソッドが実行されます。addListメソッドの中にdispatch関数を記述し、追加に関連する新規のACTIONを設定することでreducerに通知します。


<button onClick={addList}>追加</button>

もしnameに値がない場合はdispatchは行いません。またここでもsetCompleteで追加するTodoリストを未完了に設定しています。Actionのtypeを”ADD_LIST”にしています。このtypeの処理はreducerに未登録なので後ほど設定を行います。payloadにはオブジェクトでnameとcompoleteの値を入れます。nameには入力フォームで入力した値、completeはfalseが入っています。


  const addList = () => {
    if (!name) return;

    setComplete(false);

    dispatch({
      type: "ADD_LIST",
      payload: {
        name,
        complete,
      },
    });
    setName("");
  };

type”ADD_LIST”のACTIONの処理をreducerに追加します。

reducerでは現在のstateに対してACTION内に記述されている処理を適用し、新しいstateを作成します。現在のstateを直接更新しません。ADD_LISTのコードが新しいstateと現在のstateを更新するという意味の違いを理解するのが一番簡単です。

下記が新しいstateをreturnしている処理です。分割代入により…state.listsにlistsを展開し、action.payloadに入った新しいリストを加えて、新しいlistsの配列を作成してオブジェクトでreturnしています。


case "ADD_LIST":
  return {
    lists: [...state.lists, action.payload],
  };

もしstateを更新する場合は既存のlistsの配列に新たに配列の要素を追加するのでpushにより追加を行うことができます。しかしreducerでは以下の処理を記述していはいけません。


state.lists.push(action.payload)

動作確認

予定していた機能の実装が完了したので動作確認を行います。

入力フォームに新たなTodoを追加します。

入力フォームで新たなTodoを追加
入力フォームで新たなTodoを追加

完了したTodoの完了ボタンをクリックすると完了したTodoリストへ移動します。

完了したTodoリストの完了ボタンをクリック
完了したTodoリストの完了ボタンをクリック

作成したstore/index.jsファイルは下記の通りです。


import { createStore } from "redux";

const initialState = {
  lists: [
    {
      name: "ブログを確認",
      complete: false,
    },
    {
      name: "メールの返信",
      complete: false,
    },
  ],
};

const reducer = (state = initialState, action) =< {
  switch (action.type) {
    case "ADD_LIST":
      return {
        lists: [...state.lists, action.payload],
      };
    case "DONE_LIST":
      return {
        lists: state.lists.map((list) =< {
          if (list.name !== action.payload) return list;
          return {
            ...list,
            complete: true,
          };
        }),
      };
    case "DELETE_LIST":
      return {
        lists: state.lists.filter((list) =< list.name !== action.payload),
      };

    default:
      return state;
  }
};

const store = createStore(reducer);

export default store;

作成したApp.jsファイルは下記の通りです。


iimport React, { useState } from "react";
import "./App.css";
import { useSelector, useDispatch } from "react-redux";

function App() {
  const lists = useSelector((state) => state.lists);
  const dispatch = useDispatch();
  const doneList = (name) => {
    dispatch({ type: "DONE_LIST", payload: name });
  };
  const deleteList = (name) => {
    dispatch({ type: "DELETE_LIST", payload: name });
  };

  const [name, setName] = useState("");
  const [complete, setComplete] = useState(false);

  const inputText = (e) => {
    setName(e.target.value);
  };

  const addList = () => {
    if (!name) return;

    setComplete(false);

    dispatch({
      type: "ADD_LIST",
      payload: {
        name,
        complete,
      },
    });
    setName("");
  };
  return (
    <div className="App">
      <h1>ReduxでTodoリスト作成</h1>
      <input type="text" value={name} onChange={inputText} />
      <button onClick={addList}>追加</button>
      <h2>未完了のTodoリスト</h2>
      <ul>
        {lists
          .filter((list) => list.complete === false)
          .map((list, index) => (
            <div key={index}>
              {list.name}
              <button onClick={() => doneList(list.name)}>完了</button>
              <button onClick={() => deleteList(list.name)}>削除</button>
            </div>
          ))}
      </ul>
      <h2>完了したTodoリスト</h2>
      <ul>
        {lists
          .filter((list) => list.complete === true)
          .map((list, index) => (
            <div key={index}>{list.name}</div>
          ))}
      </ul>
    </div>
  );
}

export default App;