169
131

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

React #2Advent Calendar 2019

Day 1

Redux不要論と、グローバル状態管理ライブラリReactNの紹介

Last updated at Posted at 2019-11-30

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-thunkredux-sagaという二大巨頭があります。

しかし、これらって、複雑だと感じないでしょうか?
ただAPIを叩くPromiseを生成して、thenで結果を受け取りたいだけなのに(もしくはasync/awaitしたいだけなのに)、Reduxと組み合わせるために結局またボイラープレートが増えてしまいます。また、データを表示するComponentから離れた場所にロジックが置かれ、処理の流れをぱっと見で把握できなくなります。コードジャンプ無しでは読むのも辛いです。

それ、stateful component/useState hookでよくないですか?

ReduxのサンプルでTodoアプリが紹介されていますが、これを見ても牛刀をもって鶏を割いているようにしか見えません。
Todoアプリなら、プレーンなReactのstateで作れます(React.Componentを使うならthis.state。最近はuseStatehookという選択肢もあります)

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ライブラリ、formikoverviewページでは、まさにこの点が指摘されています

Why not Redux-Form?

By now, you might be thinking, "Why didn't you just use Redux-Form?" Good question.

  1. 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
  2. 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を使う意味はほぼ無いでしょう。

npm trends: redux-form vs formik [redux-form vs formik | npm trends](https://www.npmtrends.com/redux-form-vs-formik)

(補足)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です。

banner
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

こちらがルートコンポーネントです。

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が書き換わります。

FooPage.tsx
import React, {useGlobal, useEffect} from 'reactn';

const FooPage: React.FC = () => {
  const setPageTitle = useGlobal('pageTitle')[1];

  useEffect(() => {
    setPageTitle('Foo page');
  }, []);

  ......
}

PrivateRoute

また、上記App/index.tsxを見ると、ルーティングにPrivateRouteが使われていますが、これは以下のようなコンポーネントです。

auth/PrivateRoute.tsx
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の補完もバッチリです。

global.d.ts
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には触れませんでした。そちらについても、「使うメリットがあれば使う、そうでなければ使わない」にしかなりません。ただ、結局、なるべくシンプルに済ませられないか考え続けるのは大事かなと思います。

169
131
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
169
131

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?