LoginSignup
45
41

More than 1 year has passed since last update.

今から始めるRedux x React x TypeScript

Last updated at Posted at 2021-11-11

はじめに

Reduxを業務で使う必要性が出てきたのですが、調べても古い記事や分かりづらい記事しかなく苦労したので備忘録としてまとめておきます。

前提

Reactの基本的な部分が分かる
TypeScriptの基本的な部分が分かる
javasriptのES6の基本的な部分が分かる
stateの基本的な概念が分かる

用語の定義

Reduxは非常に分かりづらく、その原因となっているのが用語の多さだと思います。新しく用語が登場したら説明しますが、読む前に用語の定義に目を通しておいてください。ここではなんとなく用語の意味を理解してもらうことを目的としているので、とても簡潔に書いています。

Redux
状態管理ライブラリ

state
アプリケーションが保存しておきたい状態

store
状態を保管しておく場所

action type
状態に対してどのような操作をしたいのかを識別するための記号。

payload(ペイロード)
状態への操作に必要な引数

action
通常action typeとpayloadをもつオブジェクトのことを指す1

action creator
actionを返り値とする関数。2

reducer(リデューサー)
actionを受け取り、actionの内容に対応したstateへの変更を行う関数

dispatch
reducerにactionを渡すための関数3

selector
stateから必要なデータを抽出し、加工する処理をする関数

React-Redux
公式がだしているReact用のreduxライブラリ

Redux-Toolkit
公式が出しているReduxのベストプラクティスが詰まっているライブラリ

slice(スライス)
redux-toolkitのcreateSlice関数によって生成されるreducerとaction creatorを含むオブジェクト

immutablility(イミュータビリティ)
データを変更するのではなく、新しいデータを生成することでのみデータを入れ替えることが出来る性質4

immer
immutabaleなコードを楽に書けるライブラリ

Reduxを学ぶ必要性

Reduxは2021年11月現在Reactで最も使われている状態管理ライブラリです。つまり、現在多くのプロジェクトで導入されており、Reduxを用いたプロジェクトに関わる可能性は非常に高いです。もう用語を見ただけでうんざりな人もいると思いますが、最低限の知識はつけておく必要があるでしょう。しかし、個人的には新規のプロダクトでReduxを採用するのはおすすめしません(便利かどうかは置いといてめんどくさすぎませんか?)。要するに言いたいことは新規開発で使うのはおすすめしないけど、プロジェクトとかではみんな使ってるから使えないとまずいよねって話です。

Reduxの基本概念

すぐコードを見たいかもしれませんが、Reduxは特に概念を理解しておかないと訳が分からなくなります。まずはReduxではどのようなアプローチでstateを保存しているのか見ていきたいと思います。この章では基本的にRedux公式[1]を参考に解説していきたいと思います。

ReduxはAction5と呼ばれるイベントを用いて状態を管理・更新するためのパターンとライブラリです。大きな意味6でのReduxの概念における登場人物はstate,action,viewです。下に公式の図を示します。

redux.png
Redux公式[1]より引用

State
アプリケーションを動かすリソース

View
現在のStateに基づく記述(見た目)

Action
ユーザーの入力に基づいてアプリ内で発生するイベントで、State更新のトリガーとなる

要するにViewに対してユーザーが入力するとActionが発生してStateが更新、最後にViewが変わるよねーって話です。Reduxは上の図のような一方通行のデータフローをモデルとして作成されたということを頭に入れておいてください。

それでは先ほどのモデルの抽象度を低くして、具体的な概念とそのデータフローについて見ていきましょう。先ほどより少し抽象度を低くしたReduxの概念での新たな登場人物はStore,Reducer,dispatchです。下に公式の図を示します。
redux.gif
Redux公式[1]より引用

Store
StateとReducerを入れておく場所

Reducer
actionと現在のstateを引数としてstateの更新を行う関数

dispatch
actionをstoreまで伝播させる関数(操作)

Storeとは違い、dispatchとReducerは操作であることに注意してください。

ここから少し難しくなってきます。上の図が示すことを順に追っていきましょう。

1. ユーザーはUIから入力を行い何らかの操作をすることでイベントが発生。
2. イベントハンドラによってそのイベントに対応したアクションをdispatchする(storeに送る)。
3. actionがstoreに伝播するとstoreは以前のstateと現在のactionを用いてreducerを実行し、その戻り値を新しい状態として保存します。
4. sroreは登録しているUIのすべての部分に、storeが更新されたことを通知します。
5. storeからのデータを必要とする書くUIコンポーネントは必要なstateが変更されたかを確認します。
6. stateが変更されていた場合新しいデータで再レンダリングを行い、画面に表示される内容を更新します。

以上が一連の流れです。長々と書きましたが要するにイベントに対応するactionをイベントハンドラでdispatchすれば、storeにactionが届いてreducerがactionを使ってstateを更新してくれるということです。英語とカタカナばかりですが大丈夫でしょうか。

上の図は動作する際の動きですが、初期設定の動作もあります。Redux公式[1]を参考に下に記述します。

1. storeはreducerを一度呼び出し、その戻り値を初期状態としてstateに保存します。
2. UIコンポーネントがレンダリングされる際にstoreのstateを参照しレンダリングを行います。

最初にreducerを実行することでstateの初期状態を保存し、それを参照して初回のレンダリングが行われている訳です。

しかし、actionをdispatchすると言っていますが、どのようにactionを作成すればいいのでしょうか。そこで更に抽象度を下げてreduxの概念を見ていきましょう。Redux公式では先ほどまでの抽象度しか図では載っていなかったので、ここからは自分で作成したものを使って説明していきたいと思います。(間違ってたらごめんなさい)。DFD(データフローダイアグラム)で作成したので見方が分からない方はこちら[2]に目を通してから見ると分かりやすいと思います。今回の新しい登場人物はaction creatorだけです。

Untitled Diagram-Page-1 (1).png

action creator
actionを作成し返り値とする関数。

action creatorはpayloadを引数としてactionを作成するものです。payloadを渡しているところは書きにくかったので省きました。action creatorが作成したactionをimportして、それをdispatch関数の引数として渡すことでstoreまでactionを伝播出来ます。他の部分には特に変更はないですが、個人的に分かりやすいように変更してみました。どれが操作で、どれがデータストアかぐちゃぐちゃになりがちなのでもう一度確認してみてください。

以上でひとまずRedux単体での概念的な話は終了です。

React-ReduxとRedux-Toolkitの基本概念

さて、Reduxについての概念は終了したわけですが、ReactでReduxを使う際によく一緒に使うライブラリがReact-ReduxRedux-Toolkitです。この2つのライブラリはどちらもRedux公式がサポートしているものであり、Reduxとしても利用を推奨しています。create-react-appで作成可能なReduxテンプレートでは、どちらも最初から導入されており[3]、一緒に使うのが当たり前的な感じがあります。つまり、基本的にはこの2つのライブラリの概念も理解しなければいけない訳です。もうこの時点で使いたくなくなりますが、使う時は必ず来るので頑張りましょう。create-react-appを用いたテンプレートを実際に見てみると分かりやすいので、もし実行できる環境にある人は下記コマンドで作成し、中身を見てみてください。

npx create-react-app my-app --template redux-typescript

React-Redux

React-Redux公式[4]を参考に解説していきます。

Redux自体は独立したライブラリで、React、Angular、Vue、バニラJSなど、あらゆるUIフレームワークと組み合わせて使うことが出来ます。Reduxを何らかのUIフレームワークと一緒に使用する場合「UIバインディングライブラリ」を使用してReduxとUIフレームワークを結びつけることになります。そしてReact-ReduxがReact用の公式UIバインディングライブラリです。ReduxとReactを一緒に使っている場合は、この2つのライブラリをバインドするためにReact Reduxも使う必要があります。

要するにReactでRedux使う時はこいつも必ず必要ということです。実際に使うのは恐らくuseDispatchとuseSelectorとその型定義くらいでしょう。また、React-Reduxで新しく出てくるもの7としてSelecrtorがあります。

Selector
厳密な定義はないが、ReduxのStateから必要なデータを抽出し、Viewが扱いやすい形に加工する処理をする関数のこと([7]より引用)。

Selectorは僕もよく分かっていないのですが、データ加工とstateからの必要データの抽出が目的のようです[6]。恐らく大規模開発になるとデータの加工や抽出を使うのかもしれませんが、僕はデータを加工せずにstateをそのままUIコンポーネントにぶん投げるものとして使っています8。あとはカプセル化やパフォーマンス向上の意味もあるようです[7]。また、stateの中身が変更されているかいないかに関わらず、stateの更新を行うと関数を実行してしまう問題の解決としても使われているようです8とりあえずSelectorを使ってstate(のデータ)を取ってくることを認識しておけば大丈夫です。ここでcreate-react-appで作成されたアプリケーションのhooKs.tsの中身を見てみましょう。

hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

デフォルトでこのようにラップされており、2つのhooks「useAppDispatch」、「useAppSelector」があります。このuseAppDispatchを利用して各UIコンポーネントにdispatch関数を持ってくることになります。useAppSelectorは各UIコンポーネントでstateを持ってくることに使われます。Couter.tsxを実際に見てみましょう。

・・・
//react-reduxを使用したhooksのimport
import { useAppSelector, useAppDispatch } from "../../app/hooks";
import {
  ・・・
//actionのimport
  increment,
 ・・・
  selectCount,
} from "./counterSlice";
・・・

export function Counter() {
//stateの入手
const count = useAppSelector(selectCount);
//dispatchの入手
const dispatch = useAppDispatch();
・・・
return (
  <div>
      ・・・
    <button
          className={styles.button}
          aria-label="Increment value"
          //イベントハンドラーにactionであるincrementを引数とするdispatch関数を設定
          onClick={() => dispatch(increment())}
        >
          +
        </button>
    ・・・
  </div>
  );
}

実際のコードではこのようになります。useAppDispatchでdispatchを取ってきて、importしたactionを引数に渡せばあとはstoreにあるreducerがやってくれます。またuseSelector()の引数であるselectCountの定義は以下のようになっています。

// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state: RootState) => state.counter.value;

selectCountは全体の状態からcounterのデータを抽出する操作をする関数であるということのようです。またコメントにも書いてあるようにuseSelectorを定義側に書いてしまうのもありです。僕が使っている書き方を参考までに載せておきます。

//定義側ファイル
export const useCountSelector = () => {
    const count = useAppSelector((state: RootState) => state.count.value);
    return { count };
};

//利用するコンポーネントでの呼び出し
cosnt {count} = useCountSelector();

先程の書き方と比べて引数がいらない、オブジェクトで返しているのでvscodeのインテリセンスを利用することが可能な点で便利なので僕はこの書き方を利用しています。ただし、パフォーマンスの点は考慮していないのでそこが大切な人はしっかりと吟味してください。

少し詳しくuseSelector APIについて見ていきましょう。React-Redux公式[9]を参考に解説していきます。

useSelector API

const result: any = useSelector(selector: Function, equalityFn?: Function)

関数selectorは必ず引数とし、equalityFnはあってもなくても良い比較関数ということですね。

selectorはオブジェクトだけでなく任意の値を返すことが出来、selectorの返り値はuseSelectorの戻り値として使用されます。actionがdispatchされるとuseSelectorは以前のセレクタの結果値と現在の結果値の参照比較を行います。もし異なれば再レンダリングをし、同じであれば再レンダリングはされません。useSelectorはデフォルトで厳密な===等式チェックを使用しています。

useSelectorは1つの関数コンポーネントで何回も呼び出すことが出来、呼び出しごとにstoreへの個別のサブスクリプションが作成されます。React-Redux v7の更新バッチ処理のおかげで、同じコンポーネント内の複数のuseSelector()が新しい値を返すようなdispatchされたアクションは一回の再レンダリングにしかなりません。

useSelectorを用いてstoreから複数の値を呼び出したい場合は次のようにします
1. useSelector()を複数回呼び出し、それぞれが1つのフィールド値を返す。
2. Reselect(後述します)または類似のライブラリを使用して、複数の値を1つのオブジェクトで返すか、値の1つが変更された場合にのみ新しいオブジェクトを返すメモ型セレクタを作成する。
3. useSelector()のequalityFn引数として、React-ReduxのshallowEqual関数を使用する。

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

// later
const selectedData = useSelector(selectorReturningObject, shallowEqual)

さて長々と書いてきましたが解説していきます。

要するにuseSelectorは単体のデータのstateを返している分には問題ないけど、複数データのstateを返す際は気をつけないとパフォーマンス上の問題が出ますよという話です。例えば複数データのstateの一部分だけ変更した場合でも、毎回レンダリングされてしまうからデータを分割し、ライブラリを用いてメモ化、浅い比較をすることでパフォーマンスを改善しようということだと思います。

これについてはなるほどなーと思いましたね。ちょうど悩んでいた事だったのですが、やはりデータ自体の分割が可能ならそれが一番良いようです。Reselectはメモ化したselectorを生成するのに一番使われているライブラリです。変更無かったらキャッシュを返すことでパフォーマンスを向上させているということですね。しかし「ただ状態を取ってくるだけの処理」の場合はメモ化のコストの方が高くつきそうなので色々と考える必要はありそうです。浅い比較にしているのは恐らく計算コストを下げたいのではないでしょうか(浅い、深いがわからない人はシャローコピーなどで調べてください)。

これらの考えに基づけばやっている事自体は変わらないはずなので、自分がやっている書き方はパフォーマンス的には変わらない気がするのですがどうなんでしょうか。もし何か気になる点があればご指摘ください。

Redux-Toolkit

Redux-Toolkit公式[5]を参考に解説していきます。

Redux-ToolkitはReduxを簡単に使えるようにするためのライブラリです。Reduxのコアライブラリは意図的に独立して作成されており、すべてをどのように処理するかを決めることが出来ます。これは柔軟性があって良い場合もありますが、可能な限り単純な方法でコードを書きたい時もあると思います。そういう時にRedux-Toolkitは便利です。しかし、Redux-ToolkitではReduxでよく使用されるいくつかの他のパッケージ(ReselectやRedux-Thunk)への依存性を追加しているので注意が必要です。

要するにRedux-Toolkitは独立性を捨てて、他のパッケージや特定の考えへの依存性を高めることでより簡単に書けるようにしたライブラリということです。Redux-Toolkitで新しく登場する概念としてSlice(スライス)があります。

Slice
reducerとaction creatorを含むオブジェクト。

Sliceは最もよく使う概念で、createSlice関数を用いることでreducerとaction creatorをまとめることが出来ます。couterSlice.tsを見てみましょう。

counterSlice.ts
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { RootState, AppThunk } from '../../app/store';
import { fetchCount } from './counterAPI';

export interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

const initialState: CounterState = {
  value: 0,
  status: 'idle',
};

export const incrementAsync = createAsyncThunk(
  'counter/fetchCount',
  async (amount: number) => {
    const response = await fetchCount(amount);
    // The value we return becomes the `fulfilled` action payload
    return response.data;
  }
);

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(incrementAsync.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(incrementAsync.fulfilled, (state, action) => {
        state.status = 'idle';
        state.value += action.payload;
      });
  },
});

//action
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
//selector
export const selectCount = (state: RootState) => state.counter.value;

export const incrementIfOdd = (amount: number): AppThunk => (
  dispatch,
  getState
) => {
  const currentValue = selectCount(getState());
  if (currentValue % 2 === 1) {
    dispatch(incrementByAmount(amount));
  }
};

export default counterSlice.reducer;

createSliceではname,initialState,reducersを引数としてsliceを生成します。reducer関数の名前ととnameから自動的にaction Typeが生成されます。こうすることでaction creatorをわざわざ作成する必要がなくなります。またcreateAsyncThunkで生成した非同期actionなど、別で作成しておいたactionに対するreducerを作成するときはextraReducersを使います[10]。また、reduxToolikitには標準でreselectが入っており、パッケージを追加する必要なく利用することが可能です。

最終的なReduxのモデル

React-Redux,Redux-Toolkitの解説をしてきた訳ですが、sliceやselectorなどの登場で紹介してきたモデルとは少し異なるものになっています(根本的には同じです。)そこで紹介したモデルの図を2つのパッケージを利用した場合に変更していきたいと思います。変更したDFDがこちらです。

new redux-Page-1 (1).png

変更点はaction creatorの位置とsliceの追加、selectorの追加です。useSelectorなどは具体的すぎるのでモデルに書かない方がいいのかもしれないですが、こっちのほうが分かりやすいので書きました。state更新の通知など、selector周りは厳密には図で表現できてないですが、そこは察してください。あと非同期処理のことまで書くと散らかりそうだったので、とりあえず非同期処理のミドルウェアなどは一旦無視してます。

現状僕の中でのRedux,React-Redux,Redux-Toolikitのデータフローはだいたいこんな感じです。とりあえずReduxをやってみたい人はこの図を参考にして実装してみると分かりやすいと思います。ただこんな感じのDFD書いてる人見たことないので、もしかしたら間違っているかもしれません。おそらくめんどくさくいから書かない、理解してないから書けないのどちらかが理由だと思いますが。気になる点があればご指摘ください。

これでひとまずRedux,React-Redux,Redux-Toolkitの基本的な概念の解説は終了です。

Redux Style Guide

やっとReduxの具体的なコードの書き方の章かな〜とか思ったそこのあなた!!まだです!まだ実際にコードを書き始めるには知らないといけないことがあります。いや知らないといけないこと多すぎだろ〜と僕も思います。この章で扱うのは知っておいた方が良いReduxのルールが書いてあるRedux Style Guideについてです。この章は[18]、[19]を参考に解説しています。

Redux Style GuideはRedux公式が出している方針であり、優先度がA、B、Cの三段階に分かれています。優先度Aは必須、優先度Bは強く推奨、優先度Bは推奨となっています。すべてを解説するのは無理なので、特に重要だと思うやつだけ抜き出して紹介したいと思います。

優先度A

State を直接変更しない
Reducer以外でstateの変更は許されません。

Reducer は副作用を持ってはいけない
Reducer閉じた存在でなければならなく、関心を持っているもの以外の変数に影響を与えてはいけません。

アプリに対して 1 つの Redux Store を持つ
storeを複数持ってはいけません。

優先度B

Redux のロジック記述に Redux Toolkit を使う
公式としてもRedux Toolkitの仕様を推奨しています。

不変更新には Immer を使う
これは後で説明しますが、Immerが内蔵されているRedux Toolkitを注意して使えば大丈夫です。

ファイルをfeaturesフォルダか Ducks で構造化する
これについても後で説明しますが、featuresフォルダを用いた構造か、Ducksパターンをディレクトリ構造として推奨しています。

できるだけ多くのロジックを Reducer に配置する
どんなロジックでもReducerにしようということではなく、新しい状態を計算するために必要なロジックは出来る限りReducerに配置しようということです。理由としては下記のようなものが挙げられます。

・純粋関数であるためテストが簡単
・immutableに書ける(Redux Toolkitを使えば)
・Redux Dev Toolsを用いたデバッグが出来る

利点を簡単に言うと安全に動作し、管理しやすくなるということです。

関数コンポーネントで「useSelector」を複数回呼び出す
複数回に分けて呼び出せばいいというわけではないですが、パフォーマンスを常に意識した方が良いということでしょう。

静的型付けを使用する
TypeScriptを推奨しています。もちろんみんな使ってますよね?

デバッグに Redux DevTools 拡張機能を使用する
めちゃくちゃ便利なので絶対使いましょう。Google Chromeの拡張機能で追加できるのでやってみてください。これがあるのはReduxの大きな利点ですね。

優先度Cは重要度が低いので省略。他にもいろいろあるので、公式サイトか日本語訳に目を通してみてください。

Immutability

この章では先ほどから登場していたImmutabilityに触れていきます。Immutabilityとは不変性などを意味する英語で、プログラミングの世界では新しいデータを生成することでのみデータを入れ替えることが出来る性質を表します。僕のリサーチが正しければconstであることと、immutableであることは必ずしも同値ではないはずです(もし違ったら指摘してください)。Reduxではstateの更新状況を追跡できなくなるなどの理由からImmutableにコードを書くことが必要です。javascriptでの例を見てみましょう。この章ではRedux Toolkit公式[12]を参考に解説していきます。

const obj = { a: 1, b: 2 }
// still the same object outside, but the contents have changed
obj.b = 3

const arr = ['a', 'b']
// In the same way, we can change the contents of this array
arr.push('c')
arr[1] = 'd'

JavaScriptのオブジェクトや配列はデフォルトではすべて変更可能です。オブジェクトを作成するとそのフィールドの内容を変更することができます。配列でも同様に内容を変更することができます。またarr.push()のように代入せずに変更を行うメソッドのことを破壊的メソッドと呼びます。

値をimmutableに更新するためには、コードで既存のオブジェクトや配列のコピーを作成し、そのコピーを変更する必要があります。

const obj = {
  a: {
    // To safely update obj.a.c, we have to copy each piece
    c: 3,
  },
  b: 2,
}

const obj2 = {
  // copy obj
  ...obj,
  // overwrite a
  a: {
    // copy obj.a
    ...obj.a,
    // overwrite c
    c: 42,
  },
}

const arr = ['a', 'b']
// Create a new copy of arr, with "c" appended to the end
const arr2 = arr.concat('c')

// or, we can make a copy of the original array:
const arr3 = arr.slice()
// and mutate the copy:
arr3.push('c')

これがimmutableであるということです。データに対して直接操作するのではなく、コピーや新しく作成し、まるごと入れ替えるというのがポイントです。ちなみに単純なnumber型などはデータが一つしかないため、代入するだけでimuutableです。つまり、下のような記述は通常は許されないということです(後ほど限定的に可能になります)。

// ❌ Illegal - by default, this will mutate the state!
state.value = 123

下のコードはOKです。

// ✅ This is safe, because we made a copy
return {
  ...state,
  value: 123,
}

しかし、ここでImmerというライブラリが登場してきます。長いので省きますがなんだかんだあって、immerを用いることでmutable的に書かれているコードがimmutableに動作するようになります。そしてimmerはReduxToolkitのcreateReducerに内蔵されており、そしてcreateReducercreateSliceに内蔵されています。つまり、createSliceのreducer内では、mutable的に書かれたコードでも基本的にimmutableに動作することがimmerによって保証されているわけです。そのため、createSliceのreducer内では下のコードはimmutableに動作します。

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    todoAdded(state, action) {
      state.push(action.payload)
    },
  },
})

ただimmerにもある程度の制約はあるようなのでimmer公式サイト[13]で一度目を通しておくといいと思います。何かあった時もImmerの存在自体を知っておけば原因を見つけやすいでしょう。

また、インターネットで情報を検索する際にも気をつけてください。immutableに書けと記述しているサイトがRedux-Toolkitを使っているか、Immerを使っているかで意味が変わってきます。

Reduxのディレクトリ構成

これがReduxを使う上での最後のパーツだと僕は考えています。あと少しなので頑張っていきましょう!この章で扱うのはReduxのディレクトリ構成についてです

Reduxの概念をこれまで話してきた訳ですが、それらを見てわかる通り色々な機能を持ったものがたくさん存在します。当然なにも考えずにディレクトリ構成を作成するとめちゃくちゃなことになります。そのため、Redux用のディレクトリ構成パターンというのが存在するので、こちらのサイト[14][15]を参考に紹介していきます。

redux-way

redux-wayでは“Redux によって導入される概念” ごとにディレクトリを分ける。

[14]より引用。

src/
  ├ components/
  |    ├ component1.js
  |    └ component2.js
  ├ containers/
  |    ├ component1.js
  |    └ component2.js
  ├ reducers/
  |    ├ component1.js
  |    └ component2.js
  ├ actions/
  |    ├ component1.js
  |    └ component2.js
  └ types/
       ├ component1.js
       └ component2.js

[14]より引用。

上に示すように概念ごとにディレクトリを分けることで、分かりやすく区切っています。Reduxの考えをそのまま表したようなディレクトリ構成ですが、分割しすぎて後々めんどくさくなることが容易に想像できます。ちなみにcontainerはstoreとcomponentの間に位置する中間層のようなもので、componentのredux storeへの依存の排除などを目的としているようです[16]。正直僕もよく分かってません。redux-wayの分割しすぎてめんどくさいという欠点を解決するべく提案されたのがducksです。

ducks

ducksパターンは割と密な関係にあるreducers、 actions、 typesをまとめてmodulesというディレクトリにしてしまえという考えです。

src/
  ├ components/
  |    ├ component1.js
  |    └ component2.js
  ├ containers/
  |    ├ component1.js
  |    └ component2.js
  └ modules/
       ├ component1.js
       └ component2.js

[14]より引用。

こうすることである程度ファイルがまとまりました。しかし、modulesのファイルに色々と集まりすぎて、大規模なプロジェクトだと可読性がかなり低くなってしまいます。modules直下のファイル元のように分割するという考えもありますが、この欠点を解決するためのパターンがre-ducksです。

re-ducks

ducksのmodulus内のファイルを細かく分割することで、ducksよりも可読性をあげたパターンです。

src/
  ├ components/
  |    ├ component1.js
  |    └ component2.js
  ├ containers/
  |    ├ component1.js
  |    └ component2.js
  └ duck/
       ├ component1/
       |    ├ index.js
       |    ├ recucers.js
       |    ├ actions.js
       |    ├ types.js
       |    ├ operations.js
       |    └ selectors.js
       └ component2/
            ├ index.js
            ├ recucers.js
            ├ actions.js
            ├ types.js
            ├ operations.js
            └ selectors.js

[14]より引用。
operationとselectorが新しく追加されており、operationについては詳しい役割は分からないので言及しません。とにかくこれで大規模開発にも耐えうるディレクトリ構成になったという結論のようです。

Redux推奨のディレクトリ構成

Redux公式としても一応ディレクトリ構成についての意見を出しています。Redux公式のStyle Guide[17]では優先度Bのルールとして「ファイルをFeatuaresフォルダか、ducksパターンで構造化する」というのがありました。create-react-appのテンプレートでも使用されていたので、作成している人は見てみると分かりやすいかもしれません。下のようなディレクトリ構成です。

/src
    index.tsx
    /app
         store.ts
         rootReducer.ts
         App.tsx
    /common
         hooks, generic components, utils, etc
    /features
         /todos
             todosSlice.ts
             Todos.tsx

appディレクトリ配下にアプリケーション共通で使用するreduxファイル、common配下にコンポネーネントなどのファイル、features配下に機能ごとにディレクトリを作成してファイルを入れているようです。もしくはducksパターンが公式としては推奨のようです。Reduxは方針として分散しすぎるよりも、ある程度中央集権的な構成が好きなようです。

どのようなディレクトリ構成にするべきか

まず前提としてディレクトリ構成は状況や好みによって適したものが変わってきます。なので臨機応変にやっていくしかないのですが、世間一般的には小規模開発にはducks、中、大規模開発にはre-ducks的な感じなのかなーと調べていて感じました。また、公式としてはfeaturesディレクトリを用いた構成かducksパターンを推奨しています。特に理由が無ければcreate-react-appのreduxテンプレートで作成されるfeaturesディレクトリを用いた構成を使えばよいのではないでしょうか。

しかし、Reduxは最初の頃からはだいぶ状況が変わっているように思います。React-ReduxによるhooksAPIの提供でロジックを簡単に切り出せるようになりましたし、Redux-ToolkitのcreateSliceによってaction creatorとreducerを同時に持つことが出来ます。また必ず変えるべきだとは思いませんが、常にディレクトリ構成が正しいかを吟味していく必要があると思います。

それを踏まえて自分なりにディレクトリ構成を考えて、現在簡単なアプリケーション開発を通じて使用感を確かめている最中です。あくまでこれは僕個人の考え方であるのと、調べた感じ良い構成ではなさそうな感じがするのであまり真似しない方がいいです。一応簡単に紹介だけしておきます。


── src
   ├── App.tsx
   ├── assets
      ├── data   //データ用
      ├── style  //スタイル用
      └── type
          └── reduxType.ts //redux関係のtype
   ├── components //atomic designによるコンポーネントディレクトリ
      ├── atoms
      ├── molecules
      ├── organisms
      ├── pages
      └── templates
   ├── functions //ts関数のロジックディレクトリ
      ├── features //Reduxの状態ごとにディレクトリを作成しロジックを管理
      └── handler //イベントハンドラのロジック
   ├── hooks // React Hooksディレクトリ
      ├── useAppDispatch.ts //react-reduxのhooksもここに
      └── useAppSelector.ts
   ├── index.tsx
   ├── router
   └── store
       ├── slices  //sliceディレクトリ(この中にstateのselector hooksも入る)
       └── store.ts //redux store

根底の考えはstoreは出来る限りstateの保存場所としての役割に徹して欲しいという考えです。ReduxのReucerにロジックおこうという考えと対立しているのですが、こっちの方が見やすいので出来ればこうしたいです。reduxでまとめる考えは辞めて、type、hooksなど種類ごとのディレクトリに出来るだけ分けています。書いていないですがsliceも機能ごとではなくstateごとに作成しているので、sliceの名前にはstateの名前を含むしかありません。

functionsディレクトリは悩み中です。functionディレクトリではts関数のロジックを抜き出しています。このフォルダ構成で作成しているのがAPIを使わないFirebaseを用いたアプリケーションなのですが、非同期のAPI処理などはreducerなどになってしまうのかなあと思っています。状態に対する更新のロジックもfeatures/state名ディレクトリなどを作成してそこで保管、reducerで呼び出して使用してみようかなとしてみました。ただ、外部からとってきた自作関数にimmerが効くのかと、副作用が無いように作成するように気を付けないといけないのでなんか微妙です。

やはりReduxの概念を勉強して思うのは、Reduxは何かと一か所に集めたがります。そしてReduxという概念のもとで機能ごとに分けたがります。Reduxに依存せずに種類ごとに分けた方がすっきりすると思うのですが、やはりテストなどが面倒だから難しいのでしょうか。少なくとも僕の考えはReduxには合わないので、おとなしくRedux推奨の構成にするか、違う状態管理の方が良さそうだなーと感じます。

Reduxの使い方

やっと具体的なコードの書き方まで来ました!具体的なコードの例を出しながらステップごとに解説しようと思います。create-react-appのreduxテンプレートをベースに、インクリメントとした数字をページ間で共有することを例に挙げて考えていきます。

ステップ1 sliceの作成

まずはsliceを作成しましょう。sliceの作成はstateの作成に近い行為です。

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

import { RootState } from "../../assets/type/reduxType";
import { useAppSelector } from "../../hooks/useAppSelector";

const initialState: number = 0;

export const counterSlice = createSlice({
  name: "counter",
  initialState: initialState,
  reducers: {
    increment: (state) => {
      state += 1;
    },
    decrement: (state) => {
      state -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state += action.payload;
    },
  },
});

//action
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

//selector
export const useCountSelector = () => {
  const count = useAppSelector((state: RootState) => state.counter);
  return { count };
};

//reducer
export const counterReducer = counterSlice.reducer;

テンプレートと異なりただのnumber型になっていることと、selectorを作成せずにhooksを新たに作成していることに注意してください。オブジェクトとしてstateを返すことでvscodeのインテリセンスの利用が可能です。もしstateを加工して入手したい場合はselector hooks内に処理を書きましょう。

ステップ2 storeへの追加

storeにstateを追加するためにstore.tsに書きます。

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

ステップ3 dispatchとselectorを使用する。

まず、useDispatchの方だけ変更しておきましょう。

import { AppDispatch } from "../assets/type/reduxType";
import { useDispatch } from "react-redux";

export const useAppDispatch = () => {
  const dispatch = useDispatch<AppDispatch>();
  return { dispatch };
};

こうすることでvscodeのインテリセンスが効くようになります。

次にuseAppDispatch()を利用してdispatchを、useCountSelector()をcountをコンポーネントで入手します。あとはイベントハンドラなどでimportしたactionをdispatchすれば、stateを共有できます。もう一つ同じものを作成してルーティングを行えば共有していることを確認できます。

import { useState, VFC } from "react";

import { useAppDispatch } from "../../hooks/useAppDispatch";
import {
  decrement,
  increment,
  incrementByAmount,
  useCountSelector,
} from "../../store/slices/counterSlice";

export const ReduxPage1: VFC = () => {
  const { dispatch } = useAppDispatch();
  const { count } = useCountSelector();
  const [incrementAmount, setIncrementAmount] = useState(2);

  return (
    <>
      <div>Page1</div>
      <button onClick={() => dispatch(increment())}>インクリメント</button>
      <button onClick={() => dispatch(decrement())}>デクリメント</button>
      <input
        value={incrementAmount}
        onChange={(e) => setIncrementAmount(Number(e.target.value))}
      />
      <button onClick={() => dispatch(incrementByAmount(incrementAmount))}>
        数値分インクリメント
      </button>
      <div>{count}</div>
    </>
  );
};

sliceを作成して、storeに追加、そしてdispatchとselectorを使えばとりあえずreduxは使えます。

おわりに

長々と書いてきましたがやはりreduxはあまり好きになれないですね。学ばないといけないことが多すぎますし、ファイルの整理も難しい。開発者が取り組まないといけないのは状態管理だけではないのでやはり新規開発では使わない方がいいのではないでしょうか。しかし、概念自体は非常に参考になりますし、一度は学んで使ってみることをお勧めします。僕はたぶんrecoil使います。

参考文献

[1]:Redux Essentials, Part 1: Redux Overview and Concepts
[2]:DFD(データフロー図)ってなに?DFDの概要と書き方をあわせて紹介
[3]:Getting Started with Redux
[4]:Why Use React Redux?
[5]:Usage Guide
[6]:Reduxのモジュールアーキテクチャパターンre-ducksの実践 ― React-Redux with reselect
[7]:Learning Resources #selectors
[8]:Reduxのreselectとは
[9]:Hooks| React Redux
[10]:createSlice で楽に action と reducer を管理しよう
[11]:Reduxを用いる時にオススメしたい3つのTips
[12]:Writing Reducers with Immer
[13]:Immmer | Pitfalls
[14]:Reduxでのディレクトリ構成3パターンに見る「分割」と「分散」
[15]:【ガチ初心者】Reduxのディレクトリ構成 3種😀
[16]:react-redux hooks 時代のContainerコンポーネント
[17]:structure-files-as-feature-folders-with-single-file-logic
[18]:Redux Style Guide 日本語訳
[19]:Redux Style Guide


  1. actionは抽象的で文脈によって違うものになったりするので読みとり方に注意してください。またpayloadは必須ではありません。 

  2. payloadが必要なactionを生成する場合は引数としてpayloadを持ちます。 

  3. 正確にはstoreにactionを渡すものです。また、概念としてはただの操作です。 

  4. 原義は不変性。形容詞的に用いられる場合はimmutable(イミュータブル)がよく使われます。 

  5. さっきのactionの定義と違うじゃないかと思うかもしれないですが、ここでのactionは概念的な話です。 

  6. 「抽象度の高いレベルでの」という意味です 

  7. 正確にはReduxでも存在するが、説明していなかった概念 

  8. Reduxの公式チュートリアルでもそういう風に使っていたので、間違った使い方ではないはず 

45
41
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
41