React(Native)でよく使うRedux3兄弟
はじめに
普段はReact
やReactNative
を使って開発をしています。
状態管理にはRedux
を使っているのですが、いくつかアプリを開発してみて「だいたいこの組み合わせをしているな」というのが自分の中でテンプレとして固まってきました。
今回は個人的にReact
やReactNative
でRedux
を使って開発する際によく使うライブラリと、それらを組み合わせた簡単なサンプルを紹介したいと思います。
なお、サンプルとしてはReactNative
を対象とします。
※Redux
やFlux
といった概念の説明は省略します。
前提条件
react@16.13.1
react-naitve@0.63.3
typescript@4.0.5
それぞれバージョンが古かったりしますが、最新版でも問題ないと思います。
Redux3兄弟の紹介
自分がRedux
を使う上で多用している3種類のライブラリがあります。
勝手に「Redux
3兄弟」と呼んでいますが、まずはそれらの概要と簡単な使い方を紹介していきます。
①react-redux
- 参考:React Redux
React+Redux
ならこれを入れないで開発する方が難しいのでは?と思うくらい基本的なライブラリです。
平たく言うと「React
用のRedux
ライブラリ」です。
※Redux
という名前からよく勘違いしている方がいますが、Redux
はReact
のサポートライブラリ的位置づけではないです。
あくまで状態管理のライブラリなので、React
に限らず色々なライブラリで利用することができます。
インストール
yarn add react-redux @types/react-redux
使い方
オーソドックスにstore
中の値をボタンを押す毎に加算していくアプリを想定します。
下記のようなイメージです。
続いて実装を見ていきます。
State
画面の状態を定義します。
type State = {
counter: number;
}
export default State;
Reducer
State
の初期状態とDispatch
されてくるAction
毎の処理を定義します。
import { Action } from 'redux';
import State from './state';
// Stateの初期状態
const initialState : State = {
counter: 1
}
// 画面でDispatchされたActionから新しいStateを返却する
const reducer = (state : State = initialState, action: Action) => {
switch(action.type) {
// 加算Action
case 'INCREMENT':
return {
...state,
counter: state.counter + 1
}
default:
return state
}
}
export default reducer;
App
上記で作成したReducer
を元にstore
を作成しProvider
を通じて子孫となるContainer
に値を渡していきます。
import React from 'react';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import Counter from './CounterContainer';
// storeを作成
const store = createStore(reducer);
const App: React.FC = () => {
return (
<Provider store={store}>
<Counter />
</Provider>
);
};
export default App;
Container
今回はHooks
を使ってstore
の値の取得とAction
のDispatch
を行うようにしました。
従来通りクラスコンポーネントとして記載する場合はmapStateToProps
やmapDispatchToProps
を作ってconnect
する形になります。
import React from 'react';
import {Dispatch} from 'redux';
import {View, Text, Button} from 'react-native';
import {useDispatch, useSelector} from 'react-redux';
import State from './State';
const CounterContainer: React.FC = () => {
const dispatch: Dispatch = useDispatch();
const counter: number = useSelector((state: State) => state.counter || 1);
return (
<View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
<Text>{counter}</Text>
<Button
title="INCREMENT"
onPress={() => {
dispatch({type: 'INCREMENT'});
}}
/>
</View>
);
};
export default CounterContainer;
②redux-actions
react-redux
だけでもアプリケーションを開発することは可能です。
ただ、アプリの規模が拡大するにつれてAction
やReducer
周りの記述量が増え、管理が煩雑になっていきます。
そこで利用するのがredux-actions
です。
redux-actions
はAction
とReducer
の記述を簡略化できるライブラリです。
インストール
yarn add redux-actions @types/redux-actions
使い方
例えば先ほど登場したINCREMENT
をredux-actions
で記載すると下記のようになります。
import {Action, ActionFunction0, createAction} from 'redux-actions';
const incrementAction: ActionFunction0<Action<undefined>> = createAction(
`INCREMENT`, () => undefined,
);
生成されたincrementAction
はAction
を生成する関数です。
createAction
にはAction
のtype
文字列と、payload
を生成する関数を渡します。
incrementAction
はReducer
側でpayload
を使わないため上記のような書き方になっていますが、仮に「任意の値を加算する」というaddAction
を作る場合は以下のようになります。
この例ではcreateAction
を用いて1つずつAction
を定義していますが、createActions
を使うことで複数のAction
を一度に定義することもできます。
import {Action, ActionFunction0, ActionFunction1, createAction} from 'redux-actions';
const incrementAction: ActionFunction0<Action<undefined>> = createAction(
`INCREMENT`, () => undefined,
);
const addAction: ActionFunction1<number,Action<number>> = createAction(
'ADD', (value: number) => value,
)
続いてReducer
側です。
Reducer
側はhandleAction
やhandleActions
を使うことで記載できます。
import State from './state';
import {Action, handleActions} from 'redux-actions';
import {incrementAction, addAction} from './actions'
const initialState : State = {
counter: 1
}
const reducer = handleActions<State, any>({
[incrementAction.toString()] : (state, action: Action<void>) => ({
...state,
counter: state.counter + 1
}),
[addAction.toString()] : (state, action: Action<number>) => ({
...state,
counter: state.counter + action.payload
}),
}, initialState)
ポイントは元の記法にあったswitch
文を使ったAction
種別の分けがなくなった点だと思います。
Action
とそれに対応するState
の変化をオブジェクトとしてhandleActions
に渡しているため、状態の変化が理解しやすくなります。
③redux-saga
- 参考:Redux-Saga
redux-saga
はRedux
上の副作用を簡単に処理できるようになるライブラリです。
ここでの 「副作用」 は例えば、非同期通信などが挙げられます。
また、redux-saga
については下記の記事が非常に分かりやすくまとまっています。
ES6
のジェネレータ関数を使用しているため、それらの知識があると理解が捗ります。
ジェネレータに関しては以下の記事で解説しています。
インストール
yarn add redux-saga
使い方
redux-saga
では、特定のAction
を起点とした処理をスレッドのように記載することができます。
例えば 「HogeAction
がDispatch
されたらhoge()
とfuga()
を実行して、その結果PuniAction
をDispatch
する」 といった具合です。
ジェネレータ関数のyield
を用いることで、(非同期処理を含む)一連の処理を同期的に実行できます。
下記の例はreact-redux
とredux-actions
のサンプルにredux-saga
を追加した例です。
先のaddAction
がDispatch
されたことを起点として非同期処理を行うhandleAdd
を定義して、store
と紐づけています。
import React from 'react';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import {ActionFunction1, createAction, Action} from 'redux-actions';
import createSagaMiddleware, {SagaMiddleware} from 'redux-saga';
import {all, call, fork, put, take} from 'redux-saga/effects';
import reducer from './reducer';
import Counter from './CounterContainer';
export const addAction: ActionFunction1<number,Action<number>> = createAction(
'ADD', (value: number) => value,
)
function* handleAdd() {
while(true) {
// addActionのDispatchを起点に実行
const action: Action<number> = yield take(addAction);
const payload: number = action.payload;
console.log('addActionが実行されました');
const { result, error } = yield call(async () => {
try {
// 副作用となる何らかの非同期処理・・・
// await hoge();
return {result : ''};
} catch (error) {
return {error};
}
});
// さらに別なActionをDispatchすることもできる
// yield put(hogeAction());
}
}
// redux-sagaの複数の処理をまとめる
function* rootSaga() {
yield all([
fork(handleAdd)
])
}
// redux-saga用のミドルウェア作成
const sagaMiddleware: SagaMiddleware<Object> = createSagaMiddleware();
// storeを作成
const store = createStore(
reducer,
// sagaMiddlewareを設定
applyMiddleware(sagaMiddleware),
);
sagaMiddleware.run(rootSaga);
const App: React.FC = () => {
return (
<Provider store={store}>
<Counter />
</Provider>
);
};
export default App;
3種を使ったサンプルアプリ
それぞれ異なった特徴を持つライブラリですが、実際に組み合わせたアプリが下記になります。
郵便番号を入力して、対応する住所を非同期で取得して表示します。
住所の取得は下記のAPI
を使用しました。
またForm
を扱うにあたってreact-hook-form
を使っています。
手元の環境で動かす場合はこちらもインストールが必要です。
react-hook-form
の詳しい使い方は以下の記事にまとめてあります。
ソース
ざっくり3つのソースに分ました。
実際にアプリ開発をする場合は、もう少し細かく切り分けた方がメンテしやすいです。
store
アプリの状態と、関連するAction
とReducer
を記載します。
それぞれの処理については既に解説してるので、細かくは説明せずコメントをつけています。
import {combineReducers, createStore, applyMiddleware} from 'redux';
import createSagaMiddleware, {SagaMiddleware} from 'redux-saga';
import {all, call, fork, put, take} from 'redux-saga/effects';
import {Action, createAction, handleActions} from 'redux-actions';
// 住所情報を管理するState
class AddressState {
result: string = '';
}
// アプリ全体のState
export type AppState = {
// 画面に表示する住所
address: AddressState;
}
// 住所管理Stateの初期値
const initialState: AddressState = new AddressState();
// 住所初期化Action
export const initAction = createAction(
`init`, () => undefined,
);
// 住所取得Action
export const loadAction = createAction(
'load', (code: string) => code,
)
// 住所設定Action
export const setResultAction = createAction(
'setResult', (result: string) => result,
);
// Actionに対応するReducerを定義
const addressReducer = handleActions<AddressState, any>({
// 初期化Action
[initAction.toString()] : (state, action: Action<void>) => ({
...state,
result: '初期値',
}),
// 設定Action
[setResultAction.toString()] : (state, action: Action<string>) => ({
...state,
result: action.payload,
}),
}, initialState)
// Reducerを結合
const reducers = combineReducers<AppState>({
address: addressReducer,
});
// 住所読み込み処理
function* handleLoadData() {
while(true) {
// loadActionを起点とする
const action: Action<string> = yield take(loadAction);
// zipcloudから対応する住所を取得する非同期処理
const { payload, error } = yield call(async () => {
try {
const res = await fetch('https://zipcloud.ibsnet.co.jp/api/search?zipcode=' + action.payload);
const payload = await res.json();
return {payload};
} catch (error) {
return {error};
}
});
// 住所が正常に取得できたら設定ActionをDispatch
if(payload?.results?.length > 0) {
const { address1, address2, address3 } = payload.results[0];
yield put(setResultAction(`${address1} ${address2} ${address3}`))
}
}
}
// redux-saga処理を結合
function* rootSaga() {
yield all([
fork(handleLoadData)
])
}
// redux-saga用のミドルウェア作成
const sagaMiddleware: SagaMiddleware<Object> = createSagaMiddleware();
// store作成
const store = createStore(
reducers,
applyMiddleware(sagaMiddleware),
);
// ミドルウェアを起動
sagaMiddleware.run(rootSaga);
export default store;
AddressSearchContainer
郵便番号を入力するフォームと、各操作に対応したAction
のDispatch
を行っています。
import React, {useEffect} from 'react';
import {Dispatch} from 'redux';
import {useDispatch, useSelector} from 'react-redux';
import {Button, Text, View, TextInput} from 'react-native';
import {AppState, initAction, loadAction} from './store';
import {useForm, Controller} from 'react-hook-form';
// フォームの値を定義
interface IFormInputs {
code: string;
}
// 住所検索コンテナ
const AddressSearchContainer: React.FC = () => {
const dispatch: Dispatch = useDispatch();
const address = useSelector(
(state: AppState) => state?.address?.result || '',
);
const {control, handleSubmit, errors} = useForm<IFormInputs>();
// submit時処理
const onSubmit = (data: IFormInputs) => {
// 住所取得ActionをDispatchする
dispatch(loadAction(data.code));
};
// コンポーネント呼び出し時処理(≒componentDidMount)
useEffect(() => {
// 初期化ActionをDispatchする
dispatch(initAction());
}, []);
return (
<View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
<Controller
control={control}
render={({onChange, onBlur, value}) => (
<TextInput
style={{
borderBottomWidth: 1,
borderBottomColor: '#ccc',
width: 100,
fontSize: 20,
}}
placeholder="xxxxxxx"
onBlur={onBlur}
onChangeText={(value) => onChange(value)}
value={value}
/>
)}
name="code"
rules={{required: true, pattern: /^\d{7}$/}}
defaultValue=""
/>
{errors.code && errors.code.type === 'required' && (
<Text style={{color: 'red'}}>code is required.</Text>
)}
{errors.code && errors.code.type === 'pattern' && (
<Text style={{color: 'red'}}>code must be "xxxxxxx" format.</Text>
)}
<Text style={{margin: '2%'}}>{`住所:${address}`}</Text>
<Button title="送信" onPress={handleSubmit(onSubmit)} />
</View>
);
};
export default AddressSearchContainer;
App
store
の接続と、配下のAddressSearchContainer
への値の受け渡しを行っています。
import React from 'react';
import {Provider} from 'react-redux';
import AddressSearch from './AddressSearchContainer';
import store from './store';
const App: React.FC = () => {
return (
<Provider store={store}>
<AddressSearch />
</Provider>
);
};
export default App;
まとめ
今回はReact
やReactNative
でRedux
を使って開発をする場合によく使うライブラリの組み合わせを紹介しました。
副作用のない簡単なアプリならredux-saga
は使わなくてもいいかなと思います。
あくまで個人的ベストなので「他にもこういうライブラリがオススメ」といったものがあったらコメントにて情報提供をお願いします。
※redux-thunk
やredux-toolkit
を使っている方を見かけますが、あのあたりの使い勝手も気になるところです・・・
また、今回はコード量が多いため「ここがよくわからん」といった意見も大歓迎です。
Discussion