🍣

React(Native)でよく使うRedux3兄弟

2020/12/04に公開

はじめに

普段はReactReactNativeを使って開発をしています。
状態管理にはReduxを使っているのですが、いくつかアプリを開発してみて「だいたいこの組み合わせをしているな」というのが自分の中でテンプレとして固まってきました。

今回は個人的にReactReactNativeReduxを使って開発する際によく使うライブラリと、それらを組み合わせた簡単なサンプルを紹介したいと思います。
なお、サンプルとしてはReactNativeを対象とします。
ReduxFluxといった概念の説明は省略します。

前提条件

  • react@16.13.1
  • react-naitve@0.63.3
  • typescript@4.0.5

それぞれバージョンが古かったりしますが、最新版でも問題ないと思います。

Redux3兄弟の紹介

自分がReduxを使う上で多用している3種類のライブラリがあります。
勝手に「Redux3兄弟」と呼んでいますが、まずはそれらの概要と簡単な使い方を紹介していきます。

①react-redux

React+Reduxならこれを入れないで開発する方が難しいのでは?と思うくらい基本的なライブラリです。
平たく言うと「React用のReduxライブラリ」です。
Reduxという名前からよく勘違いしている方がいますが、ReduxReactのサポートライブラリ的位置づけではないです。
あくまで状態管理のライブラリなので、Reactに限らず色々なライブラリで利用することができます。

インストール

yarn add react-redux @types/react-redux

使い方

オーソドックスにstore中の値をボタンを押す毎に加算していくアプリを想定します。
下記のようなイメージです。

sample

続いて実装を見ていきます。

State

画面の状態を定義します。

state.ts
type State = {
    counter: number;
}

export default State;

Reducer

Stateの初期状態とDispatchされてくるAction毎の処理を定義します。

reducer.ts
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に値を渡していきます。

App.tsx
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の値の取得とActionDispatchを行うようにしました。
従来通りクラスコンポーネントとして記載する場合はmapStateToPropsmapDispatchToPropsを作ってconnectする形になります。

CounterContainer.tsx
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だけでもアプリケーションを開発することは可能です。
ただ、アプリの規模が拡大するにつれてActionReducer周りの記述量が増え、管理が煩雑になっていきます。

そこで利用するのがredux-actionsです。
redux-actionsActionReducerの記述を簡略化できるライブラリです。

インストール

yarn add redux-actions @types/redux-actions

使い方

例えば先ほど登場したINCREMENTredux-actionsで記載すると下記のようになります。

actions.ts
import {Action, ActionFunction0, createAction} from 'redux-actions';

const incrementAction: ActionFunction0<Action<undefined>> = createAction(
    `INCREMENT`, () => undefined,
);

生成されたincrementActionActionを生成する関数です。
createActionにはActiontype文字列と、payloadを生成する関数を渡します。
incrementActionReducer側でpayloadを使わないため上記のような書き方になっていますが、仮に「任意の値を加算する」というaddActionを作る場合は以下のようになります。

この例ではcreateActionを用いて1つずつActionを定義していますが、createActionsを使うことで複数のActionを一度に定義することもできます。

actions.ts
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側はhandleActionhandleActionsを使うことで記載できます。

reducer.ts
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-sagaRedux上の副作用を簡単に処理できるようになるライブラリです。
ここでの 「副作用」 は例えば、非同期通信などが挙げられます。

また、redux-sagaについては下記の記事が非常に分かりやすくまとまっています。

ES6のジェネレータ関数を使用しているため、それらの知識があると理解が捗ります。
ジェネレータに関しては以下の記事で解説しています。

インストール

yarn add redux-saga

使い方

redux-sagaでは、特定のActionを起点とした処理をスレッドのように記載することができます。
例えば HogeActionDispatchされたらhoge()fuga()を実行して、その結果PuniActionDispatchする」 といった具合です。
ジェネレータ関数のyieldを用いることで、(非同期処理を含む)一連の処理を同期的に実行できます。

下記の例はreact-reduxredux-actionsのサンプルにredux-sagaを追加した例です。
先のaddActionDispatchされたことを起点として非同期処理を行うhandleAddを定義して、storeと紐づけています。

App.tsx
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を使用しました。

postal

またFormを扱うにあたってreact-hook-formを使っています。
手元の環境で動かす場合はこちらもインストールが必要です。

react-hook-formの詳しい使い方は以下の記事にまとめてあります。
https://zenn.dev/nekoniki/articles/8cd73be1bcd186

ソース

ざっくり3つのソースに分ました。
実際にアプリ開発をする場合は、もう少し細かく切り分けた方がメンテしやすいです。

store

アプリの状態と、関連するActionReducerを記載します。
それぞれの処理については既に解説してるので、細かくは説明せずコメントをつけています。

store.ts
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

郵便番号を入力するフォームと、各操作に対応したActionDispatchを行っています。

AddressSearchContainer.tsx
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への値の受け渡しを行っています。

App.tsx
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;

まとめ

今回はReactReactNativeReduxを使って開発をする場合によく使うライブラリの組み合わせを紹介しました。
副作用のない簡単なアプリならredux-sagaは使わなくてもいいかなと思います。

あくまで個人的ベストなので「他にもこういうライブラリがオススメ」といったものがあったらコメントにて情報提供をお願いします。
redux-thunkredux-toolkitを使っている方を見かけますが、あのあたりの使い勝手も気になるところです・・・

また、今回はコード量が多いため「ここがよくわからん」といった意見も大歓迎です。

Discussion