Gabriel Abud氏の記事「Why I Stopped Using Redux」を、ご本人の許可を得て翻訳したものです。
比較的小規模なSPAであればこの記事に書かれているようにReduxはtoo muchな技術かなぁと個人的に感じます。
本記事へのコメントもなかなか賑やかでして、参考になるコメントが多くあります。ぜひ記事を読んだ後でコメントも確認してみてください。
なお、誤訳がありましたら編集リクエストを頂けると幸いです。
まえがき
ReduxはReactエコシステムにおいて革命的な技術でした。Reduxによりイミュータブルなデータをグローバルストアに保持できるようになり、コンポーネントツリーにおけるprop-drilling問題を解決しました。アプリケーション内でイミュータブルなデータを共有するために、拡張性の優れたツールであり続けています。
しかし、そもそもなぜグローバルストアが必要なんでしょうか?フロントエンドアプリケーションはそんなに複雑なのでしょうか?それとも単にReduxを使いすぎているのでしょうか?
シングルページアプリケーションの問題点
Reactといったシングルページアプリケーション(SPA)の出現はWebアプリの開発方法に多大な変化をもたらしました。バックエンドとフロントエンドを分離することで専門化や問題の分離が可能になりました。さらに状態まわりに多くの複雑さをもたらしました。
非同期でデータをフェッチすることは、フロントエンドとバックエンドの2つの場所にデータが存在しなければならないことを意味します。ネットワークレイテンシを減らすためにデータのキャッシュを維持しながら、全てのコンポーネントで利用できるようグローバルにデータを保存する最善の方法を考える必要があります。今や状態バグやデータの非正規化、最新でないデータに悩まされずにどのようにグローバルストアを維持するか検討することがフロントエンド開発の大部分を占めています。
Reduxはキャッシュではない
Reduxや類似の状態管理ライブラリを使う時に多くの人が陥る主な問題は、Reduxをバックエンドの状態のキャッシュとして扱うことです。データを取得し、reducer/actionでストアに追加し、最新の状態であることを確認するために定期的に再取得します。Reduxに多くのことをさせて、課題に対する包括的な解決策として利用しがちです。
覚えておくべき重要なことの1つは、フロントエンドとバックエンドの状態は決して同期していないことです。せいぜい同期しているかのように見える程度です。これはclient-serverモデルの欠点の1つであり、そもそもcacheが必要となる理由になります。しかしキャッシュしつつ同期した状態を維持するのは非常に複雑になるので、Reduxが推奨しているように、バックエンドの状態を1から作り直すべきではありません。
フロントエンド側でデータベースを再構築し始めると、途端にバックエンドとフロントエンドの責任の境界線が曖昧になります。フロントエンドエンジニアとしては、シンプルなUIを作るためにテーブルとその関係性に関する知識を持つ必然性はありませんし、データを正規化する最善の方法を知っている必要もありません。その責任はテーブルを設計するバックエンドエンジニアにあるべきです。バックエンドエンジニアはドキュメント化されたAPIの形でフロントエンドエンジニアに提供できます。
現在、バックエンドからのデータ管理を支援するReduxまわりのライブラリ(redux-observable, redux-saga, redux-thunkなど)が無数にありますが、それらは既に重たいボイラープレートのライブラリに複雑な層を追加しています。私はこれらのほとんどが的外れだと思っています。時には一歩前進する前に、一歩後退する必要もあります。
フロントエンド側でバックエンドの状態を管理することをやめて、定期的に更新するだけのキャッシュのように扱うのはどうでしょうか?フロントエンドをキャッシュから読み込む単純なディスプレイレイヤーとして扱えば、コードが格段に簡潔になってよりフロントエンドエンジニアが取り扱いしやすくなります。SPAを構築するデメリットのほとんどを避けながら、関心事を分離するメリットを享受できます。
バックエンドの状態へのシンプルなアプローチ
バックエンドの状態を保存するために、Redux(または類似の状態管理ライブラリ)を使うより大きな改善が見込めるライブラリがたくさんあります。
React Query
私は数ヶ月前から個人的なプロジェクトと仕事のプロジェクトのほとんどでReact Queryを使っています。React Queryは非常にシンプルなAPIとクエリ(データの取得)とミューテーション(データの変更)を管理するフックがいくつかあるライブラリです。
React Queryを使い始めてから生産性が上がっただけでなく、Reduxを使っていたときっよりも10倍少ない定型的なコードを書けました。バックエンド全体の状態を頭に入れていなくてもフロントエンドアプリケーションのUI/UXに集中しやすくなりました。
React QueryとReduxを比較するにはコードで見るとわかりやすいです。vanilla.jsとReact Hooks, axiosを使ってそれぞれの方法でサーバーからTODOリストを取得するコードを書きました。
まずはReduxを使った時の場合です。
import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import axios from 'axios';
const SET_TODOS = "SET_TODOS";
export const rootReducer = (state = { todos: [] }, action) => {
switch (action.type) {
case SET_TODOS:
return { ...state, todos: action.payload };
default:
return state;
}
};
export const App = () => {
const todos = useSelector((state) => state.todos);
const dispatch = useDispatch();
useEffect(() => {
const fetchPosts = async () => {
const { data } = await axios.get("/api/todos");
dispatch({
type: SET_TODOS,
payload: data}
);
};
fetchPosts();
}, []);
return (
<ul>{todos.length > 0 && todos.map((todo) => <li>{todo.text}</li>)}</ul>
);
};
データの再取得やキャッシュ、無効化処理はしていません。単にデータをロードして、ロード時にグローバルストアに保存しているだけです。
同じ内容をReact Queryを使って実装すると次のようになります。
import React from "react";
import { useQuery } from "react-query";
import axios from "axios";
const fetchTodos = () => {
const { data } = axios.get("/api/todos");
return data;
};
const App = () => {
const { data } = useQuery("todos", fetchTodos);
return data ? (
<ul>{data.length > 0 && data.map((todo) => <li>{todo.text}</li>)}</ul>
) : null;
};
この例では、デフォルトでデータの再取得、キャッシュ、古い状態の無効化が含まれています。キャッシュ設定はグローバルレベルで設定できますが、これは忘れても構いません。大体において期待通りに動作します。これがどのように動作するかについてはReact Queryのドキュメントを確認してください。多くの設定オプションがあり、この例はそれらのほんの一部に過ぎません。
データが必要なところであればどこでも設定したキー(この例では"todos")とデータ取得に使う非同期呼び出しをしてuseQueryフックを使えます。関数が非同期である限り、実装はそこまで難しくありません。axiosの代わりにFetch APIを使うのと同じような感じです。
バックエンドの状態を変更する用途にはReact QueryはuseMutationフックを用意しています。
React Queryのリソースをまとめたリストも書いたので、ここから確認してみてください。
SWR
SWRはコンセプト的にはReact Queryとほぼ同じです。React QueryとSWRはほぼ同時期に開発され、お互いにいい影響を与えあっています。React-Queryのドキュメントに、これら2つのライブラリの比較もあります。
React Queryのように、SWRも本当に読みやすいドキュメントが整備されています。ほとんどの場合、どちらのライブラリを使っても間違いはありません。近い将来どちらがスタンダードになるかどうかに関わらず、Reduxによる複雑さよりもこれらどちらかのライブラリを使った状態からリファクタリングするほうが遥かに簡単でしょう。
Apollo Client
SWRとReact QueryはREST APIに焦点を当てていますが、GraphQL用に類似ライブラリが必要な場合、有望な候補はApollo Clientです。構文がReact Queryとほぼ同じだとわかります。
フロントエンド側の状態はどうするか?
これらのライブラリを使えば、多くのプロジェクトでReduxはやりすぎてあると気づくでしょう。アプリのデータ取得/キャッシュの部分を処理してしまえば、フロントエンド側で処理するグローバルな状態はほとんどありません。その僅かに残った状態はContextかuseContext+useReducerを使って擬似的なReduxで処理できるでしょう。
あるいは、シンプルな状態にはReactに備わっているuseStateを使うのが良いでしょう。この対応でも本質的に何も間違っていません。
// clean, beautiful, and simple
const [state, setState] = useState();
バックエンドとフロントエンドが曖昧に分かれる状態ではなく、完全に分離された状態を受け入れましょう。これらの新しいライブラリはSPAにおける状態管理方法のシフトを表しており、正しい方向への大きな一歩です。これらライブラリがReactコミュニティをどう導くのか今から楽しみです。