React
は「自由にカスタマイズできる」かつ「日進月歩」というのがメリットの反面、
ネット上には新旧の情報が入り乱れていてベストプラクティスが見つけづらいというのがデメリットになっているのではないでしょうか。
その最たる例がHooks
、Redux
をどういった構成にするかだと思います。
巷ではHooks
はRedux
の機能を補えるから、Redux
はもはや必要ないといった意見もでているようです。
本当に不必要なのでしょうか?
初学者の方にもなるべくわかりやすいようにRedux
の概要・非同期処理について根本的なところから改めて振り返って整理したのち、Hooks(useReducer・useContext)
との設計概念・機能比較を行いました。
「もうそんなこと知ってるよ」という方は、Redux
概要の箇所は飛ばしていただけるとありがたいです。
【Redux概要】 MVC・Flux・Reduxアーキテクチャ比較
Redux
自体 MVC → Flux → Redux
と既存のアーキテクチャの欠点を補うために生まれてきたアーキテクチャのため、遠回りなように思えますが、理解を深めるためにはこれらの差異について知ることが重要になってきます。
MVCアーキテクチャ
アプリケーション開発の学習を始めると一番最初に覚えるアーキテクチャではないでしょうか? 初心者にもシンプルでわかりやすいです。私自身もプラグラミングスクールでRuby on Rails
を使い開発を行っていたのでお馴染みのアーキテクチャです。
単純なアーキテクチャであればシンプルに以下のようなフローになります。
従来型のフルサーバサイドレンダリングアプリケーションであれば、Model・View
間のデータフローは単方向ですが、React
などのSPA
アプリ開発においては、Model・View
双方向にデータの受け渡しがあるところがポイントになります。
複雑なアーキテクチャになればなるほど、Model・View
間のフローのパターンは指数関数的に増大してしまいメンテナンスが難しくなってしまいます。(以下図は概念を理解するために若干大げさな表記になっています。)
Fluxアーキテクチャ
Flux
アーキテクチャは上記MVC
モデルの煩雑なデータフローを解消するために生まれました。
つまり、シンプルなアプリであればMVC
モデルでもなんら問題はないということになりますね。
MVC
にたとえるならDispatcher
がController
でStore
がModel
といったところです。
ただし以下の点で異なっています。
-
Store
は一枚岩で複数のオブジェクトの状態を管理している(Model
のようにオブジェクトごとに個別に存在しない) -
Store
の変更は、Action
から派生するDispatcher
によってのみ行われる。View
から直接操作することはない。 -
Store
の値が変更されると、直ちにView
に反映される。
上記のようなデータフローにより、Store・View
はどれだけアプリケーションのボリュームが大きくなっても、単方向のデータフローに集約することができるようになります。つまり「どこのModel
がどこのView
を更新して〜」、「どこのView
がどこのModel
を更新して〜」、などを考えなくても単純に「View
から『何をしたいか』のAction
を発行し、その変更をStore
が受けつけ、Store
の状態が即時にView
に反映される」といったシンプルなデータフローになります。
Reduxアーキテクチャ
Redux
はFlux
アーキテクチャに更に制限を追加して、アプリケーションの状態管理をスムーズにするフレームワークです。
Flux
のデータフローに加えて以下のような制限が加わります。
-
Store
はアプリケーションに1つのみ存在する。つまり状態の更新先・参照先は常に同じStore
(シングルトン) -
Store
の状態(State
)を変更できるのは、Reducer
のみ -
Reducer
はStore
内のState
を、View
より発行された「一意のActionType
」により振り分け、新しいState
に更新する -
Reducer
は常に同じ結果を返す(非同期処理やランダムな計算を行ってはいけない)
【Redux概要】 Reduxで非同期処理を扱う
本格的なアプリケーション開発において、「非同期処理(外部APIとの連携)」を行わないケースは少ないと思います。
そのため非同期処理の実装は重要になってくるのですが、Redux
においてもMiddleware
を導入することで、非同期処理を実現することができます。
今回はMiddleware
の中でも人気のRedux-thunk
とRedux-saga
について、特徴を整理してみます。
Redux Thunk
非常にシンプルなMiddleware
で、Thunk
を導入するとActionCreater
内部のロジックで非同期処理が実行できるようになります。
React
同様、いい意味でも、悪い意味でもコード設計は自由なため、構成をうまく考えないとファットAction
になってしまうリスクもあります。
Redux Saga
Redux-saga
については、非同期処理はSaga
に完全に切り分けてRedux
とは別で処理を回すという考え方なので、ファイル数は多くなりますがredux-thunk
よりこちらのほうが各機能の役割がはっきりして処理の流れが掴みやすくなります。
ただし、Saga
の内部処理ではあまり他では見かけないジェネレータ関数を利用しているため、Thunk
よりも学習コストがかかるというデメリットがあります。
Hooksが導入されてできるようになったこと
useReducer
React
のみでも、「Action
を発行 → Reducer
内部でActionType
によりState
の値を更新 → View
の表示が変更」といったRedux
のような外部フレームワークでしか実現できなかったFlux
ベースのアーキテクチャが組めるようになりました。
ただし、Store
の概念はなく、状態の管理は各オブジェクトごとに分散して管理する必要があります。(Redux
っぽく、すべてのオブジェクトのReducer
をCombine
できなくはないようですが、それならredux
を使ったほうが効率的です。)
useContext
コンポーネントのトップレベルで定義したstate
をその配下のコンポーネント(子・孫・ひ孫・・・)から、props
でのバケツリレーなしで値を取得することができます。
useReducer と useContextを組み合わせてReduxフローを実現
useReducer
とuseContext
を組み合わせることにより、Redux
のフローを再現してみました。
下記コードは親と子の単純な例ですが、もっとコンポーネントの階層が深くなった場合でもRootコンポーネントで定義したState
にアクセス・Action
をDispatch
することが可能になり、Redux
よりも簡単かつ軽量にデータフローを実現することができます。
import React, {
FC,
useReducer,
createContext,
} from 'react';
import AppScreen from './AppScreen';
const initialState = { count: 0 };
interface StateProps {
count: number;
}
interface ActionProps {
type: string;
}
const reducer = (state: StateProps, action: ActionProps) => {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error();
}
};
interface StoreContextProps {
state: StateProps;
dispatch: ({ type }: ActionProps) => void;
}
export const StoreContext = createContext({} as StoreContextProps);
const App: FC = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StoreContext.Provider value={{ state, dispatch }}>
<AppScreen />
</StoreContext.Provider>
);
};
export default App;
import React, { FC, useContext } from 'react';
import { StoreContext } from './App';
const AppScreen: FC = () => {
const { state, dispatch } = useContext(StoreContext);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
};
export default AppScreen;
useReducer で非同期処理をする
useReducer
を使ったデータフローではMiddleware
の導入を前提としていないようですが、Middleware
なしでも非同期処理は行うことができます。
色々と調べていたのですが、Redux
のようにアーキテクチャのベストプラクティスのようなものは定まっていないように感じました。結局Redux
のようなアーキテクチャにするならRedux
を最初から導入すればいいし、違ったやり方をするのであればグローバルの状態管理項目が増えた場合にカオスになりそうな気がするし、React
の放任主義はいつものことですが、ここも悩ましいところですね...。
import React, {
FC,
useReducer,
createContext,
} from 'react';
import axios, { AxiosError, AxiosResponse } from 'axios';
import AppScreen from './AppScreen';
axios.defaults.withCredentials = true;
axios.defaults.baseURL = 'http://xxxxxxxxxxxxxxxx.com/api';
interface StateProps {
result: AxiosResponse;
isLoading: boolean;
error?: AxiosError | null;
}
interface ActionProps {
type: string;
payload?: any;
error?: boolean;
}
const initialState: StateProps = {
result: {} as AxiosResponse,
isLoading: false,
};
const reducer = (state: StateProps = initialState, action: ActionProps) => {
switch (action.type) {
case 'start':
return {
...state,
isLoading: true,
};
case 'succeed':
return {
...state,
result: action.payload?.result,
isLoading: false,
};
case 'error':
return {
...state,
result: action.payload?.error,
isLoading: false,
};
default:
throw new Error();
}
};
interface StoreContextProps {
state: StateProps;
dispatch: ({ type }: ActionProps) => void;
}
export const StoreContext = createContext({} as StoreContextProps);
const App: FC = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StoreContext.Provider value={{ state, dispatch }}>
<AppScreen />
</StoreContext.Provider>
);
};
export default App;
import React, { FC, useContext } from 'react';
import axios from 'axios';
import { StoreContext } from './App';
const AppScreen: FC = () => {
const { state, dispatch } = useContext(StoreContext);
const clickHandler = async () => {
dispatch({ type: 'start' });
try {
const result = await axios.get('/users');
if (result.status !== 200) {
throw new Error('The request has failed');
}
dispatch({ type: 'succeed', payload: { result }, error: true });
} catch (error) {
dispatch({ type: 'error', payload: { error }, error: true });
}
}
return (
<>
Result: {JSON.stringify(state.result)}
<button onClick={clickHandler}>データ取得</button>
</>
);
};
export default AppScreen;
Hooksの機能でもReduxと同じようなことはできる。それでもReduxを使うメリット
Reduxの設計原則は素晴らしい
ルールがありすぎるのは時には不自由に感じますが、コードが複雑になればなるほどその恩恵を受けることができます。その点、Redux
にはアーキテクチャに関するドキュメントがしっかりとまとまっており、この原則にある程度従っておけばそこまで的はずれな構成にならないというのは大きいです。
Hooks
を使うから、Redux
の原則よりも素晴らしいアーキテクチャが組めるようになるというなら話は別ですが、Hooks
を使っても結局Redux
のアーキテクチャを模倣してコードを組むのであれば最初からRedux
を使っておけば良いという見解です、
逆に原則やルールが必要がないほど、シンプルなアプリケーションであるならばRedux
を導入する意味はあまりないので、Hooks(useReducer・useContext)
を使ってサクッと作るのもありかと思います。
異なるコンポーネント間でのやりとりが容易になる
原則部分とかぶる部分もありますが、グローバルの状態をStore
が一手に引き受け、かつアプリ上のすべてのReducer
をコンバインしてどのコンポーネントからでも「状態の取得」、「アクションを通しての状態の変更」ができるのは便利です。
useReducer
にはStore
の概念がそもそもありません。Hooks
を使ってもRootコンポーネントでContext
を定義すればできないことはないですが、もともとそういったアーキテクチャ用のHook
ではないので開発者独自の記述方法になって後々メンテナンスに苦労するということになりかねません。
Store
の概念を使いたいなら、おとなしくRedux
を導入して、Redux
で管理するまでもないところのみuseReducer
で処理するというのが現状のベストではないでしょうか。
Middleware
により非同期処理と状態管理を分離できる
非同期通信を行うためのMiddleware
を自由に選べるのもメリットの一つです。Redux-thunk
は自由度が高く、技術者の裁量に任されてしまいますが、Redux-saga
を使用した場合、外部APIとの非同期処理はsaga
にかき分けることを強制されるので、状態管理と非同期処理を明確に分離することができます。これによりテストが書きやすく、保守性の高いプロダクトを保持することができます。
Middleware
によりアクション発行履歴(トランザクション)を管理し、巻き戻しできる
これもまたMiddleware
の話になりますが、Redux devtools
を導入することにより、グローバルStateの可視化、アクション発行履歴の管理、タイムトラベルデバッギングができるようになり、開発効率を高めることができます。
Redux with Hooks
Redux
自体もv7.1.0
以降Hooks
の恩恵を受け随分とシンプルにかけるようになりました。
また、初期導入時にはRedux Tool Kit
でコマンド一発でRedux & Hooks & Typescript
の開発環境が整うようです。
もっと早く欲しかった..。
useSelector & useDispatch
mapStateToProps
とmapDispatchToProps
をコンポーネントにconnect
せずにRedux
のStore
にアクセスできるようになったので、記述量が減るだけでなくかなりフレキシブルにRedux
を扱うことができます。
import React, { FC, useEffect } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import { fetchAllItems, deleteItem } from '../actions/item';
const mapStateToProps = (state: AllState) => ({
items: state.item.items,
isLoading: state.item.isLoading,
});
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
fetchItemsStart: () => fetchAllItems.start(),
deleteItemStart: (itemId: ItemId) => deleteItem.start(itemId),
},
dispatch,
);
interface HomeScreenProps extends ScreenNavigationProp {
items: ItemModel[];
isLoading: boolean;
fetchItemsStart: () => void;
deleteItemStart: (itemId: ItemId) => { payload: { id: number } };
}
const HomeScreen: FC<HomeScreenProps> = ({
items,
isLoading,
fetchItemsStart,
deleteItemStart,
navigation,
route,
}) => {
useEffect(() => {
(async () => {
await fetchItemsStart();
})();
}, []);
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
(async () => {
await fetchItemsStart();
})();
});
return unsubscribe;
}, [navigation]);
const deleteItem = async (itemId: number) => {
alert(`deleted ${itemId}`);
await deleteItemStart({ id: itemId });
};
return (
・・・
);
};
export default connect(mapStateToProps, mapDispatchToProps)(HomeScreen);
import React, { FC, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchAllItems, deleteItem } from '../actions/item';
const HomeScreen: FC<ScreenNavigationProp> = ({ navigation, route }) => {
const dispatch = useDispatch();
const itemState = useSelector((state: AllState) => state.item);
useEffect(() => {
(async () => {
await dispatch(fetchAllItems.start());
})();
}, []);
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
(async () => {
await dispatch(fetchAllItems.start());
})();
});
return unsubscribe;
}, [navigation]);
const deleteButtonHandler = async (itemId: number) => {
alert(`deleted ${itemId}`);
await dispatch(deleteItem.start({ id: itemId }));
};
return (
・・・
);
};
export default HomeScreen;
Redux Tool Kit
私自身使ったことがないため導入方法リンクのみ共有しておきます。
まとめ
- Hooksの登場により、Reduxは唯一の選択肢ではなくなった
- 複雑かつ大規模なプロダクトにはReduxの設計原則が最も力を発揮する
- Hooks useReducer + useContextはReduxと競合関係にあるわけではなく共存・使い分けできる
- Reduxの設計原則が必要ないほどシンプルなプロダクトならばHooksのみでグローバルの状態も管理できる
- Reduxの導入ハードルは年々下がってきている