Redux Toolkitの構成技術を触ってみた(reselect・Immer・Redux Thunk)
Redux Toolkitとは、Reduxのエコシステムの集大成である
Redux ToolkitはReduxのエコシステムから選りすぐりの技術を集大成したライブラリ。単にReduxのボイラープレートを減らすだけのライブラリではない。
以下ではRedux Toolkitの構成要素となるライブラリの基本的な使い方を確認していく。注意して頂きたいのは、以下の記述はRedux Toolkitでの書き方ではない点だ(それなら公式ドキュメントをご覧いただくのが一番である)。
複雑なものに遭遇したときは常に基本に立ち返るのが一番だ。
reselect: Storeから値を取得する処理をメモ化する
reselectは関数をメモ化をする、1ファイル100行程度の薄いライブラリ。Storeから必要な値を取得するためのロジックを記述する。使い方はテストを見てもらうのが良い(memoized composite arguments
というテストがわかりやすい)。
メモ化した関数の引数に前回と同じ値を渡すと、その関数内の処理をスキップしてメモリから前回の計算結果を返してくれる。結果、どんなに重い処理をしている関数でも、実行時間はO(1)になる。
大体以下のような書き方になる。Storeから完了したTodoのみを取得する処理を例とする。
import { createSelector } from 'reselect'
const store = {
todos: [
{ title: 'foo', isCompleted: true },
{ title: 'bar', isCompleted: false },
{ title: 'baz', isCompleted: true },
]
}
const todosSelector = (store: Store) => store.todos
const getDoneTodos = createSelector(
todosSelector,
(todos) => todos.filter(todo => todo.isCompleted)
)
store.todosの値が同じである限り、2度目以降はtodos.filter(todo => todo.isDone)
というfilter処理は再実行されない、と理解している。
Reactコンポーネント内で以下のように書くと、再レンダリングされる度にfilterの O(n) の処理 が実行されるが、reselectだとメモ化されているためO(1)であるという認識だ。
const completedTodos = useSelector(
state => state.todo.filter(todo => todo.isCompleted)
)
処理がfilterのみの場合はtodoが2億個ある場合でやっと恩恵があるかもしれないが、Storeからの値取得のロジックはともすると重くなりがちである。
Storeの一部の値が変わっていないのに再計算を毎回実行すると処理が重くなる。reselectは、その問題を回避するのに役立つ。
ちなみに「関数が重い」というとき、実行時間が長い場合とロジックが複雑であるという2つの意味がある。前者はreselectで対策できるが、後者は別の解決策が必要だ。
selector内のビジネスロジック、というかグローバルなStoreからフロントで利用する値に変換するドメインロジックが複雑になるという課題に対しては、仕様を調整するか、せめてSelectorのテストをしっかり書いておくのが良い。
なお、プレゼンテーションロジックはselectorの中に書くべきではない。Reactコンポーネントの中に書くべきだ。ViewModelのロジックをObjectMapperに書くとクリーンなコードにならず、保守性が悪化することは想像に難くない。
Immer: オブジェクトの更新をイミュータブルにする
JavaScriptオブジェクトをイミュータブルに扱えるFacebook製のライブラリ。Storeを更新するReducerと組み合わせて使う。ネストが深いオブジェクトの値を更新する際、ピンポイントで更新する値を指定できる。
いちいち{...store, foo: {...store.foo, bar: 'newValue' }}
などと書いてられない。2階層目でこれなのだから、さらに深くなると先が思いやられる。これがいわゆる spread hell である。
Immerを使うと以下のように書ける。
const reducer = (draft: State = initialState, action: Action) => {
switch (action.type) {
case 'SOME_ACTION':
draft.foo.bar = 'newValue'
break
// ...
}
}
このReducerをuseImmerReducer
というReact Hooksに渡す。
import { useImmerReducer } from 'use-immer'
const [state, dispatch] = useImmerReducer(reducer, initialState)
使い方はuseReducerと変わらない。しかし、注意して欲しいのは、useImmerReducer
に渡すreducerは返り値を返さない点である。
(draftの中身をconsole.logで確認すると { draft: foo: { proxy: {} } } }
のような形式になっていたが、内部処理を追っていないのでよくわからない。)
「reducerは純関数だ」と叩き込まれている身としては、reducerが返り値を返さない点、あたかも変数に(しかも関数の引数に!)値を再代入しているように見える書き方に最初は抵抗があった。
しかし、ピンポイントでStoreの値を更新できるので一度使ってみるとこれが便利なのだ。なお、内部では新しくオブジェクトが生成されている。これがイミュータブルなオブジェクトの更新と言われる所以である。
Redux Thunk: Reduxで非同期処理を扱う
Redux Thunkは非同期処理を扱うライブラリだ。Reduxを入れるなら必須であるといえる。ただ、もちろん非同期処理をしないフロントのアプリケーションには不要。また、Thunkを入れない場合はuseEffectの中でactionをdispatchする書き方になる(それも悪くない)。React開発者なら経験人数も多いため採用には困らない。
(2年前はReduxで非同期処理を扱うならRedux ThunkかRedux Sagaのどちらかという印象があったが、私はSaga経験者を採用市場で見かけたことがないので、新規で採用するには覚悟のいる技術だろう)
Redux ToolkitがRedux Thunkを組み込んだことにより、「Reduxで非同期処理ならThunk」というトレンドは今後も続くと見ている。
さて、Thunk自体の解説は日本語での記述も豊富なのでそちらを参照してもらうとして、ここでは所感を書く程度に留めたい。
なお、Reduxでは「Actionをdispatch → Storeを更新する」のに対して、Redux Thunkは「Async Actionをdispatch → 非同期処理 → Storeを更新する」という理解である。
Redux ThunkはReduxの世界で非同期処理を扱うライブラリである。この点を意識すると、Redux Toolkitでbuilder.addCase(asyncThunk.pending)
といった一見奇怪な書き方がボイラープレートを減らしていることを理解できるだろう。
まず確認すべきことは、Reduxは「ReactやVue.jsといったフロントエンドのライブラリから独立した、状態管理のライブラリである」という点だ。
状態管理とは詰まるところ、Storeというグローバルなオブジェクトに保持した値の一群をアプリケーションの状態と見なすことだ。内部のアプリケーションの状態がどのようであれ、表示とは無関係なのだ。
非同期処理の中でも特にバックエンドへのリクエストを考えると、idle
(リクエストを送る準備ができている状態)、pending
(返却を待っている状態)、fulfilled
(値が帰ってきた状態)、rejected
(値の取得に失敗した状態)の4つに大別できる。
例えば、ボタンをクリックすると新着メッセージを取得するアプリケーションを想像して欲しい。
それぞれの状態をUIに対応させると、idleはボタンをクリックできることがわかる(disabledではない)、pendingはボタンがdisabledになると同時にローダーがぐるぐる回っている、fulfilledはボタンが再度クリックできるようになり、メッセージが表示される、rejectedは赤いトーストが表示されて、失敗の原因をユーザーに伝える。
これらのUIは1つの例である。fulfilledに緑のトーストで表示しても良い。状態は1つである一方、表現方法は多種多様だ。簡単に切り分けると、Reduxは前者、Reactは後者をJavaScriptで扱うライブラリなのである。
さて、状態と表示が分離されていることがわかったところで、接続のことを考えなければならない。非同期処理の状態をStoreに格納し、ReactコンポーネントがStoreの変更を検知して、状態に応じた表現をする。
Redux Thunkでは、非同期処理の状態に応じてStoreの値を変更するActionを、サーバーへのリクエストの数だけ記述しなければならなかった。つまり、エンドポイント × 3状態 の数だけStoreを更新するActionの記述が必要だった。
Redux Toolkitはそのボイラープレートを減らす書き方を用意している。それがbuilder.addCase(asyncThunk.pending)
などだ(公式ドキュメントと合わせてcreateAsyncThunkのテストを読めば、理解が深まるはず)。
技術選定に当たって
Redux Toolkitに関する技術選定のポイントを簡単に記述する。覚書程度だが、幾分かでも参考になれば幸いだ。
- Redux ToolkitはReduxのエコシステムの集大成
- 小・中規模のアプリケーションには、学習コストが大きいため不向きかもしれない。
- 開発速度、リリース時期、プロダクトのライフサイクル、チームのRedux経験者の数と実力など、その他の要素の方が導入検討の要素としては大きな意味を持つため、状況による
- 公式ドキュメントにサンプルコードが多く掲載されているため、開発者間で記述にブレが少なくなるのはメリット
- useEffectの中のロジックはasync actionに記述することになる
- reselectが組み込まれているのでselectorのメモ化できるものの、プレゼンテーションロジックのメモ化は引き続きuseMemoを推奨
- React QueryやSWRといったデータフェッチをするライブラリと相性はよくない(どちらを使うべきか迷う場面が出てくるはず。迷うなら最初から全部Redux Toolkitに寄せた方が無難)
ちなみに、React Suspenseは従来のFetch on Renderをしないようにする技術であるため、Redux Toolkit(Redux Thunk)を使う限り、Suspenseの書き方はできなそうだと懸念している。ただ、Suspenseが導入されたらデータフェッチのメンタルモデルが変わるため、それから考えてもいいかもしれない。なお、SuspenseはReactの話で、Reduxとは無関係だということを付記しておく。
なお、私は本業でNext.js + SWRで中規模のアプリケーションを開発しており、エンジニアのメンバーは2人。また、フロントエンドエンジニアが4人の副業でRedux Toolkitを触っているという前提を共有したい。
Redux Toolkitに対して少し控えめなのはポジショントーク。実際の導入検討に当たってはチームの状況と相談するのが良いだろう。
Happy Coding 🎉