Redux不要論
若干強めのタイトルです。あらゆるケースでReduxが不要と主張するつもりはありません。
しかし、Reduxが不要と思われるケースでもReduxが使われることを避けるため、「Reduxは必ず採用しなければならないものではない」ということを意識していただきたく、刺激的なタイトルで始めました。
(個人的にはむしろ、積極的に採用すべき理由がなければ採用しない方が良いくらいに思っています)
(MobXとか他のライブラリについては一旦置いておきます)
Reduxのメリット
Redux's motivation 曰く、SPAに於けるstate管理とDOM操作の複雑性のうち、Reactがviewレイヤの問題を整理しようとしている。Reduxはstate管理の部分を担当し、stateの変更を予測可能なものにする(to make state mutations predictable)ことを目指しているとのことです。
そしてこれはReduxのthree principlesに反映されています。
- Single source of truth: アプリケーションのすべての状態が単一のstoreに格納される
- State is read-only: stateは読み取り専用で、変更する唯一の方法はactionである
- Changes are made with pure functions: actionによる変更はpure functionによって定義される = reducer
これらによって、以下のようなメリットがあります
- 開発中、アプリケーションの状態を調査しやすくなる
- 開発中、アプリケーション状態の永続化が可能になるなど、開発サイクルが早くなる
- Undo/Redoなどの実装が簡単
- 変更が1箇所に順々に適用されるので、微妙なrace conditionが起こらなくなる
- Actionsはプレーンなオブジェクトなので、ロギングやシリアライズ、後からのリプレイなどが容易
- reducerによって、ロジックの分割・再利用が可能になる
これらのprinciplesと、それによってもたらされる様々なメリットは素晴らしいと思います(詳しい内容は↑のページを読んでください)。
しかし、これらのprinciplesのために導入された制約は、現実的に様々な問題を引き起こします。
また、これらのメリットの内、あなたのアプリケーション開発に本当に必要なものはどれほどあるでしょうか(本当にアプリケーションワイドでのUndo/Redoを実装したいケースがどれほどあるのでしょうか)。
Reduxのデメリット
ボイラープレートの増加
よく言われるReduxのデメリットその1です。
何か一つ処理を追加するために、action(とaction-creator)とreducerの追加が必要になります。
この問題に対処するために、様々な設計やラッパーライブラリが提案されてきました。
それでも結局、背後では、actionをdispatchしてreducerでstateを更新するという処理が動いていることは意識しないといけません。
Action creatorでactionの作成をカプセル化したり、reducerを生成する関数を作ってボイラープレートを減らしたりしたとしても、それによって抽象化されたロジックの裏側を忘れることはできません。チーム全員が、そのような抽象化を行なったことを了解していないといけません。ボイラープレートを減らすための抽象化は、初見で何をしているのかが分かりづらくなるトレードオフがあります。
結局Reduxを扱うメンタルモデルは変えられず、Reduxの複雑性に向き合うことからは逃れられません。
非同期処理
よく言われるReduxのデメリットその2です。
Redux自身は非同期処理に一切関知しません。非同期処理をどこにどう実装するかはユーザに委ねられています。
とはいえ、例えばRedux公式のレシピ集にはaction creatorで非同期処理を行う例が紹介されていますし、ライブラリとしてはredux-thunkとredux-sagaという二大巨頭があります。
しかし、これらって、複雑だと感じないでしょうか?
ただAPIを叩くPromiseを生成して、thenで結果を受け取りたいだけなのに(もしくはasync/awaitしたいだけなのに)、Reduxと組み合わせるために結局またボイラープレートが増えてしまいます。また、データを表示するComponentから離れた場所にロジックが置かれ、処理の流れをぱっと見で把握できなくなります。コードジャンプ無しでは読むのも辛いです。
それ、stateful component/useState
hookでよくないですか?
ReduxのサンプルでTodoアプリが紹介されていますが、これを見ても牛刀をもって鶏を割いているようにしか見えません。
Todoアプリなら、プレーンなReactのstateで作れます(React.Component
を使うならthis.state
。最近はuseState
hookという選択肢もあります)
Todoアプリはまあチュートリアルなのでこれを以ってRedux不要論を唱えるのはもちろん不適切です。
ただ、実際に作るアプリもよく考えてみると機能的にはTodoアプリに毛が生えた程度のものだったり、
より大規模なアプリだとしても、分解してみると相互にあまり連携しないアプリが複数バンドルされているような構成のアプリ(View数が大量にあっても、各Viewは別々のデータソースのCRUD+αくらいを担当しているようなアプリとか)だったりするケースって、実はそれなりにあるのではないでしょうか。
このようなシーンでは、Reduxを使うメリットは薄いでしょう。
各view componentでAPIを叩き、結果をローカルステートに格納し、それを表示すれば良いのです。
実際、ReactはContextもHooksも備え、単体でかなりのことがスマートにできるようになっています。
Redux-Formもいらない
Reduxが登場してから、フォーム管理のためのRedux Formが登場し、人気を伸ばしてきたと記憶しています。
しかし、Formの状態管理は、大抵のケースでグローバルステートに入れなくてもよいものの代表でしょう。
キーストロークのたびにグローバルステートを書き換えたとして、それを参照してformから遠く離れたcomponentの表示を変えたいケースがどのくらいあるでしょうか(Validation/エラー表示などでform内のコンポーネントの表示を変えるだけなら、グローバルステートを経る必要はありません)。ただformの状態管理をしたいだけなのに、キーストロークのたびにactionを発行してreducerを通してグローバルステートを更新して、というのは大げさすぎないでしょうか。
後発のformライブラリ、formikのoverviewページでは、まさにこの点が指摘されています。
Why not Redux-Form?
By now, you might be thinking, "Why didn't you just use Redux-Form?" Good question.
- According to our prophet Dan Abramov, form state is inherently ephemeral and local, so tracking it in Redux (or any kind of Flux library) is unnecessary
- Redux-Form calls your entire top-level Redux reducer multiple times ON EVERY SINGLE KEYSTROKE. This is fine for small apps, but as your Redux app grows, input latency will continue to increase if you use Redux-Form.
Formikは登場以降人気を伸ばし、近年はRedux-Formを抜いています。
Formの状態管理にRedux-Formを使う意味はほぼ無いでしょう。
(補足)Redux-Formの各種便利機能(validationサポートやpristine状態の管理など)は、ステート管理の話とは別物です。これらの機能はformikでも使えます。
(補足2)Form管理の話でいうと、最近はReact Hook Formというのも出てきています。こちらはformのinput componentsをuncontrolled componentsとして扱い、onSubmitなどの必要になるタイミングでref経由で入力内容を取得することで、Reactレイヤでのキーストロークごとのstate更新を無くし、re-renderingを大幅に抑制しているようです。それによるパフォーマンスの良さと、hooksを利用したAPIの使い勝手から、このライブラリもこれから選択肢に上がってきそうです。
Redux(グローバルstate)が必要になりやすい代表的な例
さて、ここまで散々Reduxは要らないと書いてきましたが、Redux(というか、Componentごとに分断されたstateでは無い、グローバルなstate)が欲しくなることもあります。
代表的なものは、ログイン状態の管理でしょう。
ログイン中ユーザの情報はアプリの様々な場所で使われます。
ログイン中ユーザの名前を表示する場所がアプリ内の様々な場所に存在したり、またプライベートエンドポイントを叩くあらゆるAPIコールではユーザIDやアクセストークンが必要になります。
ただ、管理したい対象がログイン状態だけなら、Reduxを使わなくても
ReactのContextで十分かもしれません。
あと、「アプリケーションの状態全てをsave/restoreしたい」というようなケースもあるかもしれません。
Reduxのstateを逐一local storageに保存するようなアプリだと、
それこそRedux-Formも使ってフォームのデータも全部Reduxのstoreに入れておけば、全てのフォームの入力状態すら復元することができます。
そういった要件がある場合は、「Reduxを積極的に採用すべき理由」になると思います。ぜひ使ってください。
ReactNの紹介
React用のシンプルなグローバルステート管理ライブラリとして最近気に入っているのがReactNです。
GitHub: https://github.com/CharlesStover/reactn
(GitHubのスター数も1.5kを超えてきているのですが、日本語での紹介を全く見ません…)
このライブラリの紹介をしたい、というのがこの記事の主な目的です。ここまでのRedux不要論とかは、まあそのための掴みです。
先ほど、「管理したい対象がログイン状態だけなら、Reduxを使わなくてもReactのcontextで十分かもしれない」と書きましたが、
私は今作っているアプリでログイン状態(とページタイトル)の管理のためだけにReactNを使っています。
Contextを使うよりボイラープレートが減るからです。
なんと、「import React from 'react'
を import React, { useGlobal } from 'reactn'
に変える」だけで、グローバルステートがuseState
と同じ使い勝手で使えるようになります!
import React, { useGlobal } from 'reactn';
...略...
const App: React.FC = () => {
const [foo, setFoo] = useGlobal('foo');
...略...
setFoo('some value for foo') // グローバルステート'foo'に値をセット
return (
<div>{foo}</div> // グローバルステート'foo'に値を参照
);
}
私はhooksで利用しているので、そのような例を載せますが、従来のstateful component風の使い方もできるようです。
ReactNの利用例
以下に、実際のコードからコピーして来たコードを載せます(不要と思われる部分を消したり若干変えています&変更後の動作確認はしていません)。
- ログイン機構はFirebase Authenticationを利用
- ルータにreact-router-domを利用
App/index.tsx
こちらがルートコンポーネントです。
import React, { useState, useEffect, useGlobal } from 'reactn';
import {
BrowserRouter as Router,
Route,
Switch
} from 'react-router-dom';
import { firebaseAuth } from '../firebase';
import { AppContainer } from '../components/layout';
import PrivateRoute from '../auth/PrivateRoute';
import Login from '../auth/Login';
import Foo from '../Foo';
import PrivatePage from '../PrivatePage';
import DefaultPage from '../DefaultPage';
const App: React.FC = () => {
const [firstAuthLoading, setFirstAuthLoading] = useState(true);
const setUser = useGlobal('user')[1];
useEffect(() => {
return firebaseAuth.onAuthStateChanged(user => {
setUser(user);
setFirstAuthLoading(false);
});
}, [setUser]);
const [pageTitle] = useGlobal('pageTitle');
useEffect(() => {
if (pageTitle) {
document.title = `FooBar App "${pageTitle}"`;
} else {
document.title = 'FooBar App';
}
}, [pageTitle]);
if (firstAuthLoading) {
return <span>Authenticating...</span>;
}
return (
<AppContainer>
<Router>
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/foo" component={Foo}/>
<PrivateRoute exact path="/private" component={PrivatePage} />
<Route component={DefaultPage} />
</Switch>
</Router>
</AppContainer>
);
};
export default App;
1つ目のuseEffect
がユーザ情報に関する処理になっています。
Firebase Authenticationは便利でして、firebaseAuth.onAuthStateChanged
でコールバックを設定しておくと、ログイン状態の変更で発火してくれます。
そのコールバック内で、useGlobal
で得たsetUser
により、グローバルステートuser
にユーザオブジェクトをセットしています。
ログインページは別にあり(ここでは載せませんが)、そこでログイン処理が行われると、ログイン状態が変わり、このコールバックが発火し、ユーザオブジェクトがグローバルステートにセットされます。
ログアウトも同様で、このコールバックがuser = null
で呼ばれるので、そのままnull
をグローバルステートにセットします。
2つ目のuseEffect
はtitle要素を書き換える処理です。
グローバルステートpageTitle
を読み出し、document.title
を設定しています。
例えば、どこかの子ページで、以下のような処理でグローバルステートpageTitle
を設定することができます。
この子ページを表示した(このcomponentをマウントした)タイミングで、グローバルステートpageTitle
に'Foo page'
がセットされ、それがApp/index.tsx
で読み出されてtitleが書き換わります。
import React, {useGlobal, useEffect} from 'reactn';
const FooPage: React.FC = () => {
const setPageTitle = useGlobal('pageTitle')[1];
useEffect(() => {
setPageTitle('Foo page');
}, []);
...略...
}
PrivateRoute
また、上記App/index.tsx
を見ると、ルーティングにPrivateRoute
が使われていますが、これは以下のようなコンポーネントです。
import React, { useGlobal } from 'reactn';
import { Route, Redirect, RouteProps } from 'react-router-dom';
const PrivateRoute: React.FC<RouteProps> = props => {
const [user] = useGlobal('user');
const { children, render, ...restProps } = props;
return (
<Route
{...restProps}
render={renderProps => {
if (!user) {
return (
<Redirect
to={{ pathname: '/login', state: { from: renderProps.location } }}
/>
);
}
if (render) {
return render(renderProps);
} else {
return children;
}
}}
/>
);
};
export default PrivateRoute;
グローバルステートuser
を読み出して、ログインしていれば(user != null
)そのcomponentを表示し、ログインしていなければ(user == null
)ログインページにリダイレクトします。
これはreact-router-dom公式サンプルが元になっています。
TypeScript
また、ご覧の通りTypeScriptで書いていますが、ReactNはTypeScriptにも対応しています。
以下のように型定義を書いておけば、useGlobal
についても型推論が働きます。VSCodeの補完もバッチリです。
import 'reactn';
declare module 'reactn/default' {
export interface State {
user: firebase.User | null;
pageTitle: string | null;
}
}
まとめ
Reduxの採用を決める前に、そのアプリケーションでは本当にReduxが必要なのか、よく考えてみてください。
Reduxを入れることによって却ってコードが複雑化しかねません。
特に、ちょっとしたグローバルステートが管理できれば良いのであれば、Contextや、ぜひReactNを使ってみてください。
(補足)Reduxが不要なケースに気づいて、React組み込みのContextやReactNを使おう、という結論の文章ですので、MobXには触れませんでした。そちらについても、「使うメリットがあれば使う、そうでなければ使わない」にしかなりません。ただ、結局、なるべくシンプルに済ませられないか考え続けるのは大事かなと思います。