2019.11.04

HookとRedux ToolkitでReact Reduxに入門する


この記事では、React アプリケーションに Redux を組み合わせる方法を紹介します。

タイトルの通り、React の機能である Hook と Redux の組み込みを簡単にしてくれるライブラリ Redux Toolkit を活用した比較的新しめの方法なので、他の解説記事とは少し異なる記述になるかもしれません。

これから React や Redux を勉強する方は戸惑うかもしれませんが、導入方法がいくつかあるというだけで、どちらも間違いではないので、参考の一つとして読んでください。

Redux とは

状態管理

Redux とは、JavaScript製の状態管理ライブラリです。

「状態」とは、アプリケーションで扱う動的なデータという理解でよいでしょう。たとえばユーザー名とか、いいねの数が「状態」です。状態は時系列とともに変化します。

状態管理ライブラリが解決する問題は、コンポーネントをまたいだデータの共有です。

React でも Vue.js でも、コンポーネントは、DOM と同様にツリー構造をなします。複数のコンポーネントで同じデータを使いたい、というケースがあるとしましょう。深い階層のコンポーネントがあるデータを必要とする場合、下図のように、実際にはそのデータを利用しないコンポーネントも含めて props のバケツリレーが発生します。

これはアプリのコードを複雑にします。さらに、途中のコンポーネントに無関係な props が定義されるため、不必要な依存も産んでしまいます。

そこで考え出されたのが、状態管理ライブラリです。ストアと呼ばれるデータの入れ物を用意して、各コンポーネントが直接ストアとコミュニケーションします。これにより、上述の問題が解決されます。

Redux の特徴

Redux の特徴は、コンポーネントからのアクセスに一定のルールが設けられている点です。

そもそも React におけるデータフローが props を通した親から子への流れに限定されているのも、コードをスパゲッティ化から守るための意図があります。状態管理がグローバル変数のように濫用できてしまうと、コードにアンチパターンを仕込む結果になってしまいます。

Redux は上記のような問題に配慮し、コードを予測可能にしてバグが出にくくするため、ルールを設けています。このルールの部分が、入門者からすると難しく感じられるところかと思います。まず前提知識として説明しますが、実際に使いながら理解するとよいです。

さて、以下がコンポーネントと Redux ストアとのデータのやり取りを表した図です({} はオブジェクト、f(x) は関数であることを示しています)。

Redux では、構成要素として以下のモジュールが登場します。

State

状態を格納するオブジェクト。

{ count: 0 }

Reducer

受け取った Action(後述)に応じて State を変更する関数。より正確には、新たな State を返却する関数。

function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 }
    case 'ADD':
      return { count: state.count + action.payload }
    default:
      return state;
  }
}

Action

状態の更新を指示するオブジェクト。一般的に以下のプロパティを持ちます。

{ type: 'INCREMENT' } // typeがStateに対する操作を表す
{ type: 'ADD', payload: 3 } // payloadは任意の引数

Action Creator

Action を生成する関数。

function increment() {
  return { type: 'INCREMENT' };
}

function add(number) {
  return { type: 'ADD', payload: number };
}

処理の流れ

これらの構成要素が、以下のように連携して状態管理を実現します。

  1. コンポーネントが、Action Creator を呼び出して Action を取得する。
  2. 取得した Action を Reducer に渡す。これを dispatch という。
  3. Reducer は、渡された Action に応じて State を更新する。
  4. コンポーネントは State に変更があれば、関連する UI を書き換える。

設計のポイント

このようなルールの重要なポイントは、コンポーネントが自由に状態(State)を書き換えられない点です。状態を更新するためにはかならず Reducer に更新処理を依頼する必要があり、さらに Reducer はあらかじめ決められた Action にしか反応しません。

状態に対して定義された変更しか加えられないこのルールは、予期しない更新処理を防ぎ、コードを予測しやすくします。

また、Action Creator も不確実性の排除に一役買っています。Reducer に対しては決められた属性および値を持つ Action を渡さなければいけません。type の値など間違った Action を渡さないように、Action Creator を介して Action を取得します。

ちなみに、Redux は React 専用のライブラリではありません。ただ、Vue.js における Vuex のように、他のフレームワークにも独自の状態管理ライブラリが用意されているため、やはり React とともに利用されることがほとんどです。

Redux Toolkit

Redux 導入の課題

Redux は最小限の機能しか持たないため、導入する際は上で説明した各モジュールを自分で準備しなくてはいけません。

それらのコーディングは煩雑で、初学者にはハードルになり、ある程度習得したあとも、定型文のようなセットアップコードをいちいち書くのは面倒です。

また、アプリの規模によってはストアを適切に分割する必要もあります。たとえば State がユーザーの状態と商品の状態とカートの状態と…というように、様々な種類のデータをまとめて持つと、見通しが悪くなりそうですよね。

しかし、Redux は特定の分割方法を提供していません。ドキュメント内でいくつかの方法が提案されているのみです。ビジネスロジックではない箇所について自ら設計判断をしなければならないのは生産的とは言えません。

Redux Toolkit の登場

Redux Toolkit は、このような問題を解決するために開発されたライブラリです。

スターターキットの名前のとおり、Redux を始めやすいように、各モジュールの生成やオススメのストア分割方法をヘルパー関数として提供します。

2019/10/23 にバージョン1.0が正式リリースされており、本記事執筆時点では比較的新しいライブラリと言えます。しかしすでに Redux や React-Redux のドキュメントでも紹介されているため、React アプリ開発において create-react-app がスタンダードとなったように、Redux 導入のスタンダードとなることが期待されます。

以下で、基本的な導入手順を説明していきます。

インストール

React で Redux Toolkit を使って Redux を導入するためには、いくつかのライブラリをインストールする必要があります。

$ npm install --save @reduxjs/toolkit react-redux

必要なライブラリは以下の通りです。

ライブラリ 用途
redux Redux そのもの。
@reduxjs/toolkit に同梱されるため明示的なインストールは不要。
@reduxjs/toolkit Redux Toolkit。
react-redux React に Redux を組み込む方法を提供する。

ディレクトリ構成

ここからの説明におけるアプリのディレクトリ構成は、以下を想定しています。

src
├─ components
│  ├─ aaa.js
│  └─ bbb.js
├─ stores
│  ├─ ccc.js
│  ├─ ddd.js
│  └─ index.js
├─ App.js
└─ index.js

stores が、Redux ストアを格納するディレクトリです。ユーザー情報、カート情報などのように、扱うデータによってストアのファイルを分けてここに入れます。index.js はそれら複数のストアを結合してエクスポートします。

この構成に関しては私が考えたものなので、一意見として参考にしてください。

また、以降の説明はあくまでそれぞれが断片的なサンプルコードで、順序立ててアプリを構築するチュートリアルではありません。

ストアを作成する

Slice

まずは Redux Toolkit から提供される createSlice 関数を用いて個別のストアを作成します。

store/user.js
import { createSlice } from "@reduxjs/toolkit";

// Stateの初期状態
const initialState = {
  name: ''
};

// Sliceを生成する
const slice = createSlice({
  name: "user",
  initialState,
  reducers: {
    setName: (state, action) => {
      return Object.assign({}, state, { name: action.payload })
    },
    clearName: state => {
      return Object.assign({}, state, { name: "" })
    },
    // etc...
  }
});

// Reducerをエクスポートする
export default slice.reducer;

// Action Creatorsをエクスポートする
export const { setName, clearName } = slice.actions;

Slice とは、ストア全体を構成する一部分のストアを意味します。切り取った(= slice)一部分という意味なのでしょう。前述のように、ユーザー情報、商品情報、カート情報など、状態の内容や用途ごとに State / Reducer / Action Creator を切り分けてまとめることで、コードの見通しを良くする意図があります。

この Slice パターンは Redux の公式ドキュメントで提案されていましたが、適用するためにはドキュメントを読み込んで理解し、各自で実装する必要がありました。Toolkit は推奨されるパターンを適用するハードルを下げるため、createSlice 関数を提供しています。

createSlice は、State / Reducer / Action Creator をまとめて作成する関数と言えます。以下の形式のオブジェクトを引数として受け取ります。

createSlice({
  name: "string", // Sliceの名称
  initialState: {
    // Stateの初期状態
  },
  // Reducer
  // Stateに対して許可する更新処理を定義する場所
  reducers: {
    // ここに定義したキーがAction Creator関数の名前となる
    // つまり、Action Creatorは自動生成される
    actionName: (state, action) => {
      // 第一引数は現在(更新前)のState
      // 第二引数は渡されたaction
      // action.payloadプロパティに、Action Creatorに渡された引数が入っている
      // この関数は新しい状態を返却する
    }
  },
  // 必要に応じて追加のReducerを指定できる
  extraReducers: {
    // 別のSliceで生成されたActionに反応したい場合に定義する
    [anotherSlice.actions.actionName]: (state, action) => {
      // 新しい状態を返却する
    }
  }
});

createSlice によって作成された Slice は、以下のプロパティを持つオブジェクトです。

const slice = createSlice(/* ... */);

slice.name; // Sliceの名称
slice.reducer; // Reducer関数
slice.actions; // Action Creator関数

ストアの結合

上記の createSlice で作成した各 Reducer を一つにまとめ、ストアを生成します。

stores/index.js
import { combineReducers } from "redux";
import { configureStore } from "@reduxjs/toolkit";

// それぞれ slice.reducer を default export している前提
import userReducer from "./user";
import cartReducer from "./cart";

const reducer = combineReducers({
  user: userReducer,
  cart: cartReducer
});

const store = configureStore({ reducer });

export default store;
  1. まず、Redux が提供する combineReducers 関数で各 Slice の Reducer を結合します。
  2. 次に、Redux Toolkit が提供する configureStore 関数に、結合した Reducer を渡し、ストアを生成します。

Provider

完成したストアをアプリ内で利用するため、react-redux から提供される Provider コンポーネントを使用します。

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

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);
  1. Provider コンポーネントの store プロパティに、作成したストアを渡します。
  2. アプリ全体を Provider コンポーネントの子要素とすることで、アプリ内でストアにアクセスできるようになります。

ここまでがストアの作成手順です。次に、アプリ内で作成したストアの状態を参照する方法を紹介します。

ストアを参照する

状態を参照するには、react-redux が提供する useSelector フックを用います。

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

function myComponent() {
  const name = useSelector(state => state.user.name);
  const email = useSelector(state => state.user.email);

  return (
    <>
      <h1>Hello, {name}</h1>
      <h2>{email}</h2>
    </>
  );
}

useSelector には、State 内の特定の値を抜き出すためのセレクター関数を渡します。

以下のように、combineReducers で Reducer を結合した際のキーが、セレクター関数内で State の特定の一部を選び出すキーとなります。

// 状態定義
const fooInitialState = { aaa: 123 };
const barInitialState = { bbb: 456 };

/* ---------------------------------- */

// Reducer結合
const reducer = combineReducers({
  foo: fooReducer, // キーが foo なので、state.foo として状態にアクセスできる
  bar: barReducer,
});

/* ---------------------------------- */

// セレクターによる状態へのアクセス
useSelector(state => state.foo.aaa); // -> 123
useSelector(state => state.bar.bbb); // -> 456

useSelector は、特定の State の値を返します。State に変更があった場合は、自動的に再実行され、コンポーネントの再描画を促します。

ストアを更新する

ストアの値を更新するには、useDispatch フックを用います。

import React from "react";
import { useDispatch } from "react-redux";
import { someAction, anotherAction } from "../stores/foo";

function myComponent() {
  const dispatch = useDispatch();

  return (
    <>
      <button
        onClick={() => dispatch(someAction())}
      >click</button>

      <button
        onClick={() => dispatch(anotherAction(`params`))}
      >click</button>
    </>
  );
}

useDispatchdispatch 関数を返します。

Action(Action Creator の実行結果)を引数に dispatch 関数を実行することで、対応する Reducer が作動し、State が更新されます。

// Reducer 定義
const slice = createSlice({
  reducers: {
    someAction: state => {/* ... */}
  },
  // 以下略
});

// Action Creator をエクスポートする
export const { someAction } = slice.actions;

/* ---------------------------------- */

// Action Creator をインポートする
import { someAction } from "path/to/stores";

// コンポーネント側で Action を dispatch する
dispatch(someAction());
// -> Slice 作成時に定義した someAction 関数が実行される

Action Creator に引数を渡した場合、Reducer 関数側では action.payload としてその引数が渡ってきます。

// Action Creator を引数付きで呼び出し
dispatch(anotherAction(123));

/* ---------------------------------- */

// Reducer 定義
const slice = createSlice({
  reducers: {
    anotherAction: (state, action) => {
      console.log(action.payload); // -> 123
      // 以下略
    }
  },
  // 以下略
});

非同期処理

Redux Toolkit では、非同期処理もサポートされています。

非同期処理は、Slice の生成とは別に、関数として定義します。非同期処理を表す関数は、dispatch 関数を引数に取る関数を返却しなければいけません。

const slice = createSlice({
  // ...
  reducers: {
    setListItems: (state, action) => {
      // ...
    }
  }
});

export const { setListItems } = slice.actions;

// 非同期処理は関数としてエクスポートする
export function someAsyncTask() {
  // dispatch 関数を引数に取る関数を返却する
  return async function(dispatch) {
    try {
      const response = await api.doSomething();
      // 非同期処理の中で定義した Action Creator を呼び出すことができる
      dispatch(setListItems(response.data));
    } catch(err) {
      // エラー処理
    }
  }
}

非同期処理は、上述のストアの更新パターンと同様に、dispatch することで実行します。

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

// 非同期処理をインポート
import { setListItems } from "../stores/demo";

function myComponent() {
  const dispatch = useDispatch();

  // Action と同様に dispath する
  const onClick = () => dispatch(setListItems());

  return <button onClick={onClick}>click</button>;
}

非同期処理には、redux-thunk ライブラリが利用されています。

DevTools

Redux DevTools Extension は、Redux ストアの状態をリアルタイムで確認できる、非常に便利な Web ブラウザの拡張機能(Chrome 版 / Firefox 版)です。

Redux を用いて開発する際はデバッグ効率の観点から導入を強くオススメします。

しかしこの DevTools はブラウザに拡張機能を追加しただけでは動作せず、アプリケーションコード側で設定を記述する必要があります。

Redux Toolkit はこの点もフォローしていて、configureStore でストアを作成すると自動的に DevTools の設定も裏で行います。そのため、すぐに DevTools を使い始められます。

サンプルアプリ

最後に、サンプルアプリを紹介します。猫の GIF をランダムに表示するアプリです。

非同期処理も含んだ、動作するアプリ例ですので、上記までの説明を組み合わせた実例として参考にしていただければと思います。以下がアプリの仕様です。

  • Play をクリックすると、API から GIF の URL を取得する。
  • API への問い合わせ中は青いローディングメッセージを表示する。
  • Clear をクリックすると、表示中の GIF をクリアして初期表示に戻る。

開発者ツールの動きを確認したい場合はこちらにアクセスしてみてください。

ひとつ注意点があります。実際は、複数のコンポーネントで状態を共有したいケースで Redux を使いましょう。

Redux を使うとレイヤーが増える=複雑性が増すので、濫用は避けて、必要に応じて適用するのが賢明です。上のサンプルはそのケースに当てはまりませんが、あくまで使用法の説明のために Redux を導入しています。

おわりに

以上、この記事では、React アプリケーションに Redux を導入する方法を紹介しました。

Redux を導入すると Reducer や Action Creator など登場人物が増えるので、説明を読んだだけではすぐに理解しきれない箇所もあると思います。しかし、練習を重ねれば、どことどこがどう繋がっているのか見えてきて、点と点が線になるはずです。

この記事が理解の一助となれば幸いです。