RELATIONS Developers Blog

RELATIONS株式会社の開発ブログです。

Reduxを完全に廃止し、Apollo Clientに乗り換えた話

こんにちは、フロントエンドエンジニアの池田です。 前回のWistantのフロントエンド技術スタックを刷新した話に引き続き、 今回はWistant上でReduxを廃止した背景と、刷新の経緯について話してければと思います。

Redux廃止の背景

Reduxはもう必須ではない

前提として、前の記事で書いた内容を再び述べようと思います。

Global Stateの管理はモダンなWeb開発において、重要なトピックとなってます。 Redux は長らく React を使う上でのGlobal Stateのデファクトスタンダードになってましたが、 それに対する様々なアプローチが可能になった(Apollo Client や React Hooks の登場)今は、最近はその傾向が薄れて来ました。

全ページのUI改修

同じく、前の記事で述べたのですが、全ページのUIを改修するタスクに伴い、一からフロントエンドの基盤を作り直すことで、今まで積み残されていた技術的負債や開発者経験(DX)の改善も同時に行っていくことが決まりました。その技術選定を改める際、Reduxを廃止することを決定しました。

Reduxを使いながら感じた問題点

開発者経験(DX)の低さ

単純に実装量が多いことが開発者経験を妨げていました。 例えば、以前の技術スタックでは新しいエンティティーのAPIを追加する場合、以下のプロセスが必要でした。

1. reducerを作る(action types、action creator含む)
2. entityのnormalizeのためのコードを書く
3. GraphQLのquery/mutationを書く
4. APIを呼び出す関数を書く
5. 4を呼び出すredux-sagaのコードを書く
6. selectorを書く

新しいentityではない場合でも、3〜6の過程が必要になり、UI/UX以外での実装コストが高く感じました。 また、APIの改修が必要になった時も、ファイルが色んな場所に分散されていたため、修正箇所がわかりづらい場合が多々ありました。

バンドルサイズの増加

アプリケーションの規模が大きくなるに連れ(エンティティーの増加)、reducerの数も増加しました。問題はバンドルした時の root.jsのサイズも共に上がり(他の問題も重なったりして、parsedで3 MB以上に及ぶ大きさを誇ってました)、アプリケーションのパフォーマンス(主にファーストペイント)に影響している点でした。

f:id:so99ynoodles:20200610144021p:plain
様々なreducerとselector、entitiesが存在していた

ユーザーが最初にアクセスするページはログイン画面またはホーム画面で、その時必要なreducerのみダウンロードすれば良いのですが、すべてのreducercombineReducersでまとめてるため、それができてない状態でした。

コードスプリッティング すれば良い話では?と言われればその通りですが、最初からそれができるアーキテクチャーにしておらず、改修に多くのコストが掛かる恐れがあったため、着手できてませんでした。

他の技術との噛合

v5.0を開発する際に、 Apollo ClientRelay など、GraphQL のリクエストと状態管理を楽にしてくれるライブラリーを使用することを決めていたので、Reduxを引き続き使用する必要性を感じませんでした。

ReduxをどうReplaceしたか

実装量が多いかつコードが分散されていることが歪みとしてあったので、それらをコンパクトにまとめることにしました。 バンドルサイズは、reducer を廃止し、コードスプリッティングを徹底することで解決できました。(parsedで1 MB以下になりました)

APIリクエストとデータをApollo Clientに任せる

Redux 廃止前は、fetch してきたデータをストアに保存し、normalize まで独自で管理してましたが、 Apollo Client ではデータのキャッシュを自動で管理してくれるので、そこらへんを考えなくても良くなりました。 また、APIリクエストは実際それを使用するページやコンポーネントにまとめることで、どこを修正すれば良いのかをわかりやすくしました。

Reduxを使ってたときは、APIリクエストで返ってきたデータを Store に入れて、Selector を使ってUIに適用する形でしたが、Apollo Client はデータドリブンなUIに特化してるので、APIリクエストで返ってきたデータをそのままUIに適用する実装にしてます。

しかし、どうしてもキャッシュされたデータが必要な場合があります。(例:ユーザー検索など) そういう時は readFragment などのメソッドを使用すればOKです。

Redux

// selector
const userSelectorFactory = id: string => createSelector(
  allUsersSelector,
  allUsers => allUsers.find(user => user.id === id)
);

// usage
return connect(createStructuredSelector({
  user: (_, props) => userSelectorFactory(props.userId)
}))(UserComponent);

Apollo Client

const __typename = 'User';
const UserFragment = gql`
  fragment __User on User {
    id
    displayName
    avatarUri
    status
    roles
  }
`;

// util
const getUserFromCache = (
  userId: string,
  fragment: DocumentNode = UserFragment,
): User | undefined => {
  return client.readFragment({
    id: `${__typename}:${userId}`,
    fragment,
  });
};

// usage
const user = getUserFromCache(userId);

注意する点としては、readFragment とか readQuery は、必ずそのデータや Query が呼ばれていることを前提としているため、使い場所を選ぶということですね。

Conext APIとReact HooksでGlobal Stateを管理する

Global State は、ユーザーの情報をアプリ上で管理するために必要でした。 その代案としては MobXApollo Client のローカルステートなどが挙げられますが、今回は Context APIReact Hooksを使うことにしました。

選定理由は別途のライブラリーが要らず、学習コストがかからないためです。

Redux

// reduxのconnectを使う(v7.1.0以前)
return connect(createStructuredSelector({
  me: meSelector
}))(Component);

Apollo Client

// useContextを使う
const { me } = useContext(MeContext);

// or custom hooksを使う
const me = useMe();

useContext を使えば、どのコンポーネントでも簡単に Global State にアクセスできるので、便利ですね。

Redux も v7.1.0でhooksに対応するようになり、useSelector などで同様の書き方ができますが、Wistantではv5.0.5を3年前から使っていたので、そのような実装はできてませんでした。

気になったのは、アプリケーションのGlobal StateContext を分解しているため、Provider が無限に重なっていくこととですね……。

const App = () => {
  return (
    <ThemeProvider>
        <NowProvider>
          <HideProvider>
            <ModalProvider>
              <SideSheetProvider>
                <PulldownProvider>
                  <EditProvider>
                    <AppRouter />
                  </EditProvider>
                </PulldownProvider>
              </SideSheetProvider>
            </ModalProvider>
          </HideProvider>
        </NowProvider>
    </ThemeProvider>
  );
};

ネストされた Provider をまとめる方法もいくつかあったのですが、そこまでクリティカルな問題でもないので、一旦このままにしております。

Redux-Formについて

Redux を使うならRedux-Form も一緒に採用される傾向がありました。 Wistantでも Redux-Form を使ってた時代がありますが、Redux 廃止の6ヶ月以上前に Formik に移行しました。

Redux および Redux-Form の作者であるDan Abramov氏やコミュニティーの結論は、フォームの状態管理を Global State で管理すべきではないということです。レポジトリREADME.md にもその旨のメッセージが書いており、React Final Form などを使うことを推奨しています。

最後に

Redux は現在もグローバルな状態管理をするために使えるツールですが、Context API の登場後、React の基本APIで同じような実装が可能になってるので、今後自分が採用することはないと思います。最近話題のFacebook製の状態管理ライブラリー Recoil などは今度試してみたいですね。廃止後に登場したため候補には入らなかったので。