はじめに
こんにちは、スタメンでエンジニアをしているミツモトです。
スタメンでは、Web アプリのクライアント側の状態管理に Redux というライブラリを採用しています。
Redux によって API のレスポンスやローカルで扱う値を自由に状態管理できますが、ディレクトリ構成・処理の定義場所など、全体の設計は実装者に委ねられます。
いくつかのプロジェクトで Redux を採用する中で、実装上の冗長な部分、拡張性の低い部分がわかるようになり、所属するフロントエンドG内で設計について話すようになりました。そこからベストプラクティスを学ぼうという流れになり、「Redux Style Guide」を社内のフロントエンド勉強会で取り上げ、プロジェクトへの導入を行いました。
今回はそれについて紹介します。
Redux Style Guide による学び
公式はこちら
Redux Style Guide のルールは、必須・強く推奨・推奨の3つのカテゴリに分けられています。 例えば必須だと、「 state に直接変更を加えない」、「 Reducer で非同期処理を行わない」などが挙げられます。スタメンでも必須のルールは守れていましたが、強く推奨・推奨のルールで守れていない部分がいくつかありました。以下がその例です。
Redux Toolkit の採用
Redux Toolkit は効率的に Redux で開発するために、公式が出しているライブラリになります。Redux DevToolsにも対応しており、ベストプラクティスが組み込まれた関数を利用できます。
スタメンではこれまでAction、Reducerを分けて実装していましたが、Redux Toolkit を採用することで、Action、Reducer を slice としてまとめることができ、結果としてコーディング量を減らすことが出来ました。詳しくは後述の「プロジェクトへの導入」で触れます。
機能別またはDucks パターンによるファイル構成
Reduxではディレクトリ・ファイルを自由に構成できます。よく見るのはsrc配下をReduxが提供する機能やRedux-Sagaなどのミドルウェアで分けるタイプ別のパターンです。
- /src
- /actions
- /containers
- /sagas
- /reducers
スタメンでもこのパターンを採用していましたが、複数のドメイン(機能)を扱うようになると、各ディレクトリが複雑になります。Style Guideでは、機能別または Ducks パターンでディレクトリを分けることを強く推奨しています。機能別だと、src配下に機能単位で Component と Redux の処理が格納されます。
- /src
- /common(共通のComponent や Hooks など)
- /features
- /todos
- todosSlice.ts
- todoSagas.ts
- Todos.tsx
- /posts
- postsSlice.ts
- postSagas.ts
- Posts.tsx
- /todos
Ducks パターンだと Redux の処理が modules 配下で機能ごとにまとめられます。
- /src
- /components
- /common(共通のComponent や Hooks など)
- /modules
- todos.ts
- posts.ts
これらを参考に、スタメンでもドメイン単位で Redux の処理をまとめるようにしました。こちらも詳細は「プロジェクトへの導入」で触れます。
出来る限り Reducer にロジックを置く
Action を dispatch する際、Component の関数内にロジックを書くのではなく、Reducer 内に状態更新のロジックを書くことで、テストしやすくなります。また、Reducer に記述することで Redux DevTools によるタイムトラベルデバッグを行うことができ、予期しない状態変化の原因を早く特定できます。
Style Guide だと以下のように例が挙げられています。
Component内にロジックがある場合
// Component - Click handler: const onTodoClicked = id => { const newTodos = todos.map(todo => { if (todo.id !== id) return todo return { ...todo, id } }) dispatch({ type: 'todos/toggleTodo', payload: { todos: newTodos } }) } // Reducer: case "todos/toggleTodo": return action.payload.todos;
Reducer内にロジックがある場合
// Component - Click handler: const onTodoClicked = (id) => { dispatch({type: "todos/toggleTodo", payload: {id}}) } // Reducer: case "todos/toggleTodo": { return state.map(todo => { if(todo.id !== action.payload.id) return todo; return {...todo, id: action.payload.id}; }) }
ロジックが Component に依存しないことで、 Presentational Component として見通しがよくなり、描画に関する処理に集中できます。また、Component としての再利用性も高まります。
ネスト/リレーショナルによる複雑な状態の正規化
state の構造は自由に設定できるため、API のレスポンスがネストしている場合など、複雑なデータをそのまま state に保存することもできます。しかしネストしているデータは更新しづらく、特定のデータを抽出する処理が毎回必要になります。Style Guide ではデータを正規化した状態で保存する(ネストせず、エンティティごとに状態を持つ)ことを強く推奨しています。
以下が公式で挙げられている例です。
ネストしている場合
state: { posts: [ { id: 'post1', author: { username: 'user1', name: 'User 1' }, body: '......', comments: [ { id: 'comment1', author: { username: 'user2', name: 'User 2' }, comment: '.....' }, { id: 'comment2', author: { username: 'user1', name: 'User 1' }, comment: '.....' } ] } ] }
正規化している場合
state: { posts : { byId : { "post1" : { id : "post1", author : "user1", body : "......", comments : ["comment1", "comment2"] }, }, allIds : ["post1"] }, comments : { byId : { "comment1" : { id : "comment1", author : "user2", comment : ".....", }, "comment2" : { id : "comment2", author : "user3", comment : ".....", }, }, allIds : ["comment1", "comment2"] }, users : { byId : { "user1" : { username : "user1", name : "User 1", }, "user2" : { username : "user2", name : "User 2", }, }, allIds : ["user1", "user2", "user3"] } }
このように正規化することで特定のデータを抽出しやすくなり、パフォーマンスとしても良くなります。
プロジェクトへの導入
Style Guideを学び、直近のプロジェクトで最も効果があったのは、Redux Toolkitの導入とファイル・ディレクトリ構成の最適化です。
Redux Toolkit の導入により、Action と Reducer を slice としてまとめることができ、Action を定義せず、 slice で定義した関数を dispatch できます。
導入前
// Action export const FETCH_DATA = 'FETCH_DATA' export const fetchData = () => { type: FETCH_DATA, } // Reducer const Reducer = (state, action) => { switch (action.type) { case FETCH_DATA: { const { payload } = action return { ...state, data: payload } } } }
導入後
// Slice const Slice = createSlice({ name: data, initialState, reducers: { fetchData: (state) => { return { ...state, data: payload } } } })
1つ1つの Action で見ると大きな差はありませんが、アプリケーションの規模が大きくなると Action 数も増えるため、それらを定義する手間が無くなるのは嬉しいです。state の更新処理を追加する時は slice と sagas のみを触れば良いため、機能を追加しやすくなりました。
ファイル・ディレクトリ構成は Style Guide を参考にしつつ、以下のような構成にしました。
- /src
- /modules
- /domain1
- /slice
- /sagas
- /domain2
- /slice
- /sagas
- /common(ドメイン間で共通の処理)
- /domain1
- /pages
- /components
- /domain1
- /domain2
- /common(ドメイン間で共通のComponent)
- /containers
- /components
- /utils(共通のHooksなど)
- /modules
Redux の処理を modules 配下の各ドメインで分け、さらに slice や sagas の処理で分けます。state を受け取る Component は pages というディレクトリに分かれており、描画と Redux のロジックを切り離すことで責任を分離しています。このような構成にすることで、既存の処理を修正する時は該当部分を見つけやすくなり、機能追加を行う時も迷わず新しいドメインを定義して実装できるようになりました。
まとめ
Redux Style Guide を学ぶことで、より良い Redux の設計を知ることができます。振り返ると、自分たちの設計・実装に疑問を持ち、フロントエンド勉強会で取り上げ、メンバーで議論したことが実践への近道だったように思います。今後も普段触れる技術に疑問を持ちながら、必要であれば勉強会で取り上げることを続けていきたいと思います。
スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持ってくれた方は、ぜひ下記の募集ページをご覧ください。