はじめに こんにちは、スタメンでエンジニアをしている手嶋です。普段は、React+TypeScriptでフロントエンドメインで開発をしています。 弊社のプロジェクトではフロントエンドの状態管理ライブラリとして Redux を使用していますが、直近のプロジェクトにおいて Redux の Store に格納するデータを正規化することで多くの恩恵を得られた為、今回はそのメリット及び具体的な正規化の方法について紹介したいと思います。 ※ Redux の 公式ドキュメント でも Store の正規化が推奨されています。 正規化の概要及びメリットについて 正規化の概要 正規化されたデータがどのようなデータなのか示すために以下に例を挙げます。 今回はユーザーによるプロフィール入力があるアプリケーションにおけるAPIを例に挙げます。(ユーザーが各質問に対して回答を行うようなデータです) 実際のプロジェクトにおける正規化の対象としては、APIから取得したデータ全般を想定していただければと思います。 正規化前のデータ(APIレスポンス) const profileData = [ { id: 'questionId1' , name: '勤務先' , answers: [ { id: 'answerId1' , content: '東京都渋谷区' , private : false , //公開非公開の設定 } , ] , } , { id: 'questionId2' , name: '性別' , answers: [ { id: 'answerId2' , content: '男性' , private : false , } , ] , } , { id: 'questionId3' , name: '学歴' , answers: [ { id: 'answerId3' , content: 'hoge高校' , private : true , } , { id: 'answerId4' , content: 'fuga大学' , private : true , } , // 同じような学歴のデータが続く ] , } , // 同じような質問と回答のデータが続く ] 上記のままでも Store に格納することはできますが、以下のデメリットが発生します。 各エンティティ(上記でいうquestionsとanswers)のデータが混在して Store に保存されているため、どこかを更新する際に、その対象が適切に更新されているか不明瞭である データがネスト化されていることで、データの更新ロジックが複雑になったり、処理に想定以上の時間を要する可能性がある(特にデータが多い場合) イミュータブルなデータの更新はそのデータの全ての親要素の更新も伴う為、不必要なデータの更新が起こることになり、結果的にコンポーネントで不要な再描画が発生する可能性が高い(上記の例でいうと、ある単一のanswerに対する更新であってもprofileData全体への更新処理となる為、profileDataを参照している全てのコンポーネントで再描画が発生してしまいます) 以上を踏まえて正規化したデータが以下になります。 正規化後のデータ // questionのテーブル const questions = { ids: [ 'questionId1' , 'questionId2' , 'questionId3' ] , questionId1: { id: 'questionId1' , name: '勤務先' , answerIds: [ 'answerId1' ] } , questionId2: { id: 'questionId2' , name: '性別' , answerIds: [ 'answerId2' ] } , questionId3: { id: 'questionId3' , name: '学歴' , answerIds: [ 'answerId3' , 'answerId4' ] } , } // answerのテーブル const answers = { answerId1: { id: 'answerId1' , content: '東京都渋谷区' , private : false } , answerId2: { id: 'answerId2' , content: '男性' , private : false } , answerId3: { id: 'answerId3' , content: 'hoge高校' , private : true } , answerId4: { id: 'answerId4' , content: 'fuga高校' , private : true } , } Redux/Store が正規化されている状態を定義すると以下となります。 データの重複がない データ(エンティティ)ごとに「テーブル」をstateとして持っている 正規化されたデータはIDをkeyとして、データ自体がそのvalueとなる IDを持つ配列は順序を示す 正規化のメリット メリットとしては以下が挙げられます。 Store の整合性が常に取れている ネストが浅いので複雑性がない。また、値の更新に伴う不必要な値の巻き込み更新が最小限になる 値の更新時にjsの配列操作であるfilterなどを用いなくてもダイレクトに参照可能である(例えばanswerId3を更新したい場合 state.answers[answerId3] のようにシンプルに参照することができます。これはデータ探索の観点でパフォーマンスの向上が期待できます。) 前述のように本来操作が必要の無いデータに対する操作(更新/削除などの処理)が無くなる為、コンポーネント再描画などの観点から パフォーマンス面 においても恩恵を得られる 正規化の方法について 次に正規化の具体的な手法を紹介します。 弊社のプロジェクトでは基本的に normalizr というライブラリを使っています。導入方法や詳細は公式の github が参考になると思います。 APIからデータ取得後、Storeにデータを格納する直前に正規化の処理を挟んでいます。以下は上述の profileData を正規化する場合の例となります。 normalizrを使った正規化の例 処理としては大きく2段階になっており、エンティティ毎の schema を定義する処理と、実際にAPIのレスポンスを normalize して呼び出し元に値を返す処理です。 あくまで一例ですので、同じよう schema 定義と normalize をする事であらゆるデータを正規化する事ができると思います。 import { normalize , NormalizedSchema , schema } from 'normalizr' import { ProfileDataType } from 'types/profile' // エンティティ毎のschemaを定義する関数 export const createProfileDataSchema = () => { const profileData = new schema. Entity ( 'questions' , { profileAnswers: [ new schema. Entity ( 'answers' ) ] , } ) return [ profileData ] } // Reducerから呼び出す関数。APIから取得したデータを引数として受け取り、正規化後の結果を返す。 export const profileDataNormalizer = ( profileData: ProfileDataType [] ) => { const profileDataSchema = createProfileDataSchema () return normalize ( profileData , profileDataSchema ) } また、弊社のプロジェクトでAPIのデータが木構造になっている複雑なケースもありましたが、上記の処理を少し変更するだけ同じように正規化する事ができました。以下に続けて紹介します。 APIレスポンスが木構造の場合 APIのレスポンスでquestionが木構造になっており、ある質問の子要素として別の質問があるパターンです。 const profileData = [ { id: 'questionId1' , name: '勤務先' , answers: [ { id: 'answerId1' , content: '東京都渋谷区' , private : false , } , ] , } , { id: 'questionId2' , name: '基本情報' , // ここに木構造のデータとしてchildrenのデータが含まれる children: [ { id: 'questionId5' , parentQuestionId: 'questionId2' , name: '性別' , answers: [ { id: 'answerId5' , content: '男性' , private : false , } , ] , } , { id: 'questionId6' , parentQuestionId: 'questionId2' , name: '誕生日' , answers: [ { id: 'answerId6' , content: '1月1日' , private : false , } , ] , } , ] , } , { id: 'questionId3' , name: '学歴' , answers: [ { id: 'answerId3' , content: 'hoge高校' , private : true , } , { id: 'answerId4' , content: 'fuga大学' , private : true , } , // 同じような学歴のデータが続く ] , } , // 同じような質問と回答のデータが続く ] 正規化の処理 この場合は、正規化の処理の中でchildrenを定義する処理を追加することで、同じように正規化することができます。 // normalizrを使った正規化の処理 import { normalize , NormalizedSchema , schema } from 'normalizr' import { ProfileDataType } from 'types/profile' // エンティティ毎のschemaを定義する関数 export const createProfileDataSchema = () => { const profileData = new schema. Entity ( 'questions' , { profileAnswers: [ new schema. Entity ( 'answers' ) ] , } ) // 木構造のデータを扱う場合に追加した処理 profileData.define ( { children: [ profileData ] } ) return [ profileData ] } // Reducerから呼び出す関数。APIから取得したデータを引数として受け取り、正規化後の結果を返す。 export const profileDataNormalizer = ( profileData: ProfileDataType [] ) => { const profileDataSchema = createProfileDataSchema () return normalize ( profileData , profileDataSchema ) } APIの仕様次第で独自で実装した方が費用対効果が大きいケースもあると思いますが、ある程度複雑なAPIであっても normalizr でスムーズに正規化することが可能ですので、正規化の一つの選択肢として十分候補になると考えています。 おわりに Redux/Store に格納するデータを正規化するメリットや手法について紹介しました。 正規化するコスト以上に得られるメリット( Store の複雑性の排除・コンポーネント再描画対策)が大きいと感じたので、今後のプロジェクトにも導入していきたいと思っています。 最後まで読んで頂きありがとうございました。 スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。 スタメン エンジニア採用サイト インフラエンジニア募集ページ サーバーサイドエンジニア募集ページ フロントエンドエンジニア募集ページ モバイルアプリエンジニア募集ページ デザイナー募集ページ 参考にさせていただいた資料 https://tech.aptpod.co.jp/entry/2020/06/26/090000 https://zenn.dev/irico/articles/e5ae4b7d23fb69