Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
57
Help us understand the problem. What is going on with this article?
@stranger1989

ReduxとReact (Native)Hooksとの共存

Reactは「自由にカスタマイズできる」かつ「日進月歩」というのがメリットの反面、
ネット上には新旧の情報が入り乱れていてベストプラクティスが見つけづらいというのがデメリットになっているのではないでしょうか。

その最たる例がHooksReduxをどういった構成にするかだと思います。
巷ではHooksReduxの機能を補えるから、Reduxはもはや必要ないといった意見もでているようです。

本当に不必要なのでしょうか?

初学者の方にもなるべくわかりやすいようにReduxの概要・非同期処理について根本的なところから改めて振り返って整理したのち、Hooks(useReducer・useContext)との設計概念・機能比較を行いました。
「もうそんなこと知ってるよ」という方は、Redux概要の箇所は飛ばしていただけるとありがたいです。

【Redux概要】 MVC・Flux・Reduxアーキテクチャ比較

Redux自体 MVC → Flux → Redux と既存のアーキテクチャの欠点を補うために生まれてきたアーキテクチャのため、遠回りなように思えますが、理解を深めるためにはこれらの差異について知ることが重要になってきます。

MVCアーキテクチャ

アプリケーション開発の学習を始めると一番最初に覚えるアーキテクチャではないでしょうか? 初心者にもシンプルでわかりやすいです。私自身もプラグラミングスクールでRuby on Railsを使い開発を行っていたのでお馴染みのアーキテクチャです。

単純なアーキテクチャであればシンプルに以下のようなフローになります。
従来型のフルサーバサイドレンダリングアプリケーションであれば、Model・View間のデータフローは単方向ですが、ReactなどのSPAアプリ開発においては、Model・View双方向にデータの受け渡しがあるところがポイントになります。
MVC.png
複雑なアーキテクチャになればなるほど、Model・View間のフローのパターンは指数関数的に増大してしまいメンテナンスが難しくなってしまいます。(以下図は概念を理解するために若干大げさな表記になっています。)
MVC_complex.png

Fluxアーキテクチャ

Fluxアーキテクチャは上記MVCモデルの煩雑なデータフローを解消するために生まれました。
つまり、シンプルなアプリであればMVCモデルでもなんら問題はないということになりますね。

MVCにたとえるならDispatcherControllerStoreModelといったところです。
ただし以下の点で異なっています。

  • Storeは一枚岩で複数のオブジェクトの状態を管理している(Modelのようにオブジェクトごとに個別に存在しない)
  • Storeの変更は、Actionから派生するDispatcherによってのみ行われる。Viewから直接操作することはない。
  • Storeの値が変更されると、直ちにViewに反映される。

上記のようなデータフローにより、Store・Viewはどれだけアプリケーションのボリュームが大きくなっても、単方向のデータフローに集約することができるようになります。つまり「どこのModelがどこのViewを更新して〜」、「どこのViewがどこのModelを更新して〜」、などを考えなくても単純に「Viewから『何をしたいか』のActionを発行し、その変更をStoreが受けつけ、Storeの状態が即時にViewに反映される」といったシンプルなデータフローになります。

Flux.png

Reduxアーキテクチャ

ReduxFluxアーキテクチャに更に制限を追加して、アプリケーションの状態管理をスムーズにするフレームワークです。
Fluxのデータフローに加えて以下のような制限が加わります。

  • Storeはアプリケーションに1つのみ存在する。つまり状態の更新先・参照先は常に同じStore(シングルトン)
  • Storeの状態(State)を変更できるのは、Reducerのみ
  • ReducerStore内のStateを、Viewより発行された「一意のActionType」により振り分け、新しいStateに更新する
  • Reducerは常に同じ結果を返す(非同期処理やランダムな計算を行ってはいけない)

Redux.png

【Redux概要】 Reduxで非同期処理を扱う

本格的なアプリケーション開発において、「非同期処理(外部APIとの連携)」を行わないケースは少ないと思います。
そのため非同期処理の実装は重要になってくるのですが、ReduxにおいてもMiddlewareを導入することで、非同期処理を実現することができます。
今回はMiddlewareの中でも人気のRedux-thunkRedux-sagaについて、特徴を整理してみます。

Redux Thunk

非常にシンプルなMiddlewareで、Thunkを導入するとActionCreater内部のロジックで非同期処理が実行できるようになります。
React同様、いい意味でも、悪い意味でもコード設計は自由なため、構成をうまく考えないとファットActionになってしまうリスクもあります。
ReduxThunk.png

Redux Saga

Redux-sagaについては、非同期処理はSagaに完全に切り分けてReduxとは別で処理を回すという考え方なので、ファイル数は多くなりますがredux-thunkよりこちらのほうが各機能の役割がはっきりして処理の流れが掴みやすくなります。
ただし、Sagaの内部処理ではあまり他では見かけないジェネレータ関数を利用しているため、Thunkよりも学習コストがかかるというデメリットがあります。

ReduxSaga.png

Hooksが導入されてできるようになったこと

useReducer

Reactのみでも、「Actionを発行 → Reducer内部でActionTypeによりStateの値を更新 → Viewの表示が変更」といったReduxのような外部フレームワークでしか実現できなかったFluxベースのアーキテクチャが組めるようになりました。
ただし、Storeの概念はなく、状態の管理は各オブジェクトごとに分散して管理する必要があります。(Reduxっぽく、すべてのオブジェクトのReducerCombineできなくはないようですが、それならreduxを使ったほうが効率的です。)

useContext

コンポーネントのトップレベルで定義したstateをその配下のコンポーネント(子・孫・ひ孫・・・)から、propsでのバケツリレーなしで値を取得することができます。

useReducer と useContextを組み合わせてReduxフローを実現

useReduceruseContextを組み合わせることにより、Reduxのフローを再現してみました。
下記コードは親と子の単純な例ですが、もっとコンポーネントの階層が深くなった場合でもRootコンポーネントで定義したStateにアクセス・ActionDispatchすることが可能になり、Reduxよりも簡単かつ軽量にデータフローを実現することができます。

App.tsx(root)
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;
AppScreen.tsx
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.png

useReducer で非同期処理をする

useReducerを使ったデータフローではMiddlewareの導入を前提としていないようですが、Middlewareなしでも非同期処理は行うことができます。
色々と調べていたのですが、Reduxのようにアーキテクチャのベストプラクティスのようなものは定まっていないように感じました。結局ReduxのようなアーキテクチャにするならReduxを最初から導入すればいいし、違ったやり方をするのであればグローバルの状態管理項目が増えた場合にカオスになりそうな気がするし、Reactの放任主義はいつものことですが、ここも悩ましいところですね...。

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

AppScreen.tsx
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の可視化、アクション発行履歴の管理、タイムトラベルデバッギングができるようになり、開発効率を高めることができます。
e23206f8dbc0ff1acb9f2ff6ed01118a.gif

Redux with Hooks

Redux自体もv7.1.0以降Hooksの恩恵を受け随分とシンプルにかけるようになりました。
また、初期導入時にはRedux Tool Kitでコマンド一発でRedux & Hooks & Typescriptの開発環境が整うようです。
もっと早く欲しかった..。

useSelector & useDispatch

mapStateToPropsmapDispatchToPropsをコンポーネントにconnectせずにReduxStoreにアクセスできるようになったので、記述量が減るだけでなくかなりフレキシブルに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);
HooksAPIを使った書き方
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

私自身使ったことがないため導入方法リンクのみ共有しておきます。

https://redux-toolkit.js.org/introduction/quick-start

まとめ

  • Hooksの登場により、Reduxは唯一の選択肢ではなくなった
  • 複雑かつ大規模なプロダクトにはReduxの設計原則が最も力を発揮する
  • Hooks useReducer + useContextはReduxと競合関係にあるわけではなく共存・使い分けできる
  • Reduxの設計原則が必要ないほどシンプルなプロダクトならばHooksのみでグローバルの状態も管理できる
  • Reduxの導入ハードルは年々下がってきている
57
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
stranger1989
React、Typescript、AWSを駆使してWebアプリケーションの開発を行っています。
moff
介護施設、リハビリ施設用向けアプリを開発・運営するIoTスタートアップ

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
57
Help us understand the problem. What is going on with this article?