3
1

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.

Reduxの"Normalizing"パターンをTypeScriptで攻略する

Last updated at Posted at 2020-04-11

はじめに

Reduxで深いネストを持つstate管理のベストプラクティスの一つに、データを平坦にして(Normalizing)管理する方法がReduxの公式ページで紹介されています。公式ページではbyIdallIdsという2つの変数を使って解説されています。

Normalizing State Shape

TypeScriptでのbyIdの型定義やreducerの実装で試行錯誤したことを共有します。

対象読者

  • Normalizingパターンを知らない人で
    • ネストが深いデータを管理していてパフォーマンスが気になる
    • データのネストが深くてreducerを書くのがしんどい
  • Normalizingパターンを知っているけど
    • byIdの型定義方法が分からず、とりあえずanyにしている
    • reducerでbyIdの操作が難しいと感じる

Normalizingパターンとは?

Normalizeされていないデータの問題点

まずは公式ページで紹介されているようにNormalizeされていないデータをご覧ください。

Normalize前

[
  {
    id: 'post1',
    author: { username: 'user1', name: 'User 1' },
    body: '......',
    comments: [
      {
        id: 'comment1',
        author: { username: 'user2', name: 'User 2' },
        comment: '.....'
      },
      {
        id: 'comment2',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      }
    ]
  },
  {
    id: 'post2',
    author: { username: 'user2', name: 'User 2' },
    body: '......',
    comments: [
      {
        id: 'comment3',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      },
      {
        id: 'comment4',
        author: { username: 'user1', name: 'User 1' },
        comment: '.....'
      },
      {
        id: 'comment5',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      }
    ]
  }
  // and repeat many times
]

このような構造のデータが抱える問題点について、公式ページではざっくり次のように言っています。

  • 同じデータが複数の場所にあるケースだとupdateのreducer書くの大変だよね
  • そもそもネスト深いとreducer書くの大変だよね
  • 木構造の枝データが更新された場合でも、幹側のViewコンポーネント全てに無駄なrender走っちゃうからパフォーマンス悪いよね

Normalizingパターンが解決してくれること

次に、先ほどのデータをNormalizeしたものをご覧ください。

Normalize後
{
    posts : {
        byId : {
            "post1" : {
                id : "post1",
                author : "user1",
                body : "......",
                comments : ["comment1", "comment2"]
            },
            "post2" : {
                id : "post2",
                author : "user2",
                body : "......",
                comments : ["comment3", "comment4", "comment5"]
            }
        },
        allIds : ["post1", "post2"]
    },
    comments : {
        byId : {
            "comment1" : {
                id : "comment1",
                author : "user2",
                comment : ".....",
            },
            "comment2" : {
                id : "comment2",
                author : "user3",
                comment : ".....",
            },
            "comment3" : {
                id : "comment3",
                author : "user3",
                comment : ".....",
            },
            "comment4" : {
                id : "comment4",
                author : "user1",
                comment : ".....",
            },
            "comment5" : {
                id : "comment5",
                author : "user3",
                comment : ".....",
            },
        },
        allIds : ["comment1", "comment2", "comment3", "commment4", "comment5"]
    },
    users : {
        byId : {
            "user1" : {
                username : "user1",
                name : "User 1",
            },
            "user2" : {
                username : "user2",
                name : "User 2",
            },
            "user3" : {
                username : "user3",
                name : "User 3",
            }
        },
        allIds : ["user1", "user2", "user3"]
    }
}

Normalizingパターンとは、データをテーブルごとに管理してリレーションを持たせる手法です。より具体的には、byIdidをkeyにデータを持っています。またデータに順序を持たせる目的でallIdsがそれぞれのデータのidのみを配列で持っています。

公式ページではNormalizingによってもたらされる改善点を4つ挙げています。

  • データの実体は1つしか存在しないから更新が簡単です。
  • ネストが浅いのでreducerを書くのが簡単です。
  • データを探すのに必要な情報は、テーブルとIDの2つだけ。
  • データはそれぞれ独立しているから、View側で無駄なrenderが走りません。

Normalizingパターンの採用について

データの更新が行われない、またはAPIからのデータ取得によってデータが一括で書き換わりreducerが複雑にならないケースではNormalizingパターンは採用しなくてもいいかもしれません。

公式ページでも以下のような例を挙げているので、いつでもNormalizingパターンを使うべきとは考えていないようです。

stateでテーブルデータをまとめる親keyは"entities"がオススメらしいです
{
    simpleDomainData1: {....},
    simpleDomainData2: {....},
    entities : {
        entityType1 : {....},
        entityType2 : {....}
    },
    ui : {
        uiSection1 : {....},
        uiSection2 : {....}
    }
}

TypeScriptによるNormalizingパターンのコード例

Normalizingパターンの紹介が済んだところで、TypeScriptでのコード例を示していきます。

byIdの型定義

Normalizeしなければ型定義はとてもシンプルです。

以下のコードは、VSCodeの"Paste JSON as Code"というプラグインを使ってNormalize前のデータから型定義を生成しました。


// Generated by https://quicktype.io

export interface State {
  blogPosts: BlogPost[];
}

export interface BlogPost {
  id:       string;
  author:   Author;
  body:     string;
  comments: Comment[];
}

export interface Author {
  username: string;
  name:     string;
}

export interface Comment {
  id:      string;
  author:  Author;
  comment: string;
}

ではNormalizingパターンで表現されたデータはどうでしょう。

試しに再び"Paste JSON as Code"を使ってみました。

// Generated by https://quicktype.io

export interface State {
  posts:    Posts;
  comments: Comments;
  users:    Users;
}

export interface Comments {
  byId:   CommentsByID;
  allIds: string[];
}

export interface CommentsByID {
  comment1: Comment;
  comment2: Comment;
  comment3: Comment;
  comment4: Comment;
  comment5: Comment;
}

export interface Comment {
  id:      string;
  author:  string;
  comment: string;
}

export interface Posts {
  byId:   PostsByID;
  allIds: string[];
}

export interface PostsByID {
  post1: Post;
  post2: Post;
}

export interface Post {
  id:       string;
  author:   string;
  body:     string;
  comments: string[];
}

export interface Users {
  byId:   UsersByID;
  allIds: string[];
}

export interface UsersByID {
  user1: User;
  user2: User;
  user3: User;
}

export interface User {
  username: string;
  name:     string;
}

予想はしていましたがbyIdが全然ダメですね。もちろん〜ById以外は使えそうです。

byIdは未知のkeyを持ちますが、そのvalueの型は決まっています。以下のように型定義できます。


export interface State {
  entities: {
    posts: Posts;
    comments: Comments;
    users: Users;
  };
}

export interface Comments {
  byId: CommentsByID;
  allIds: string[];
}

export interface CommentsByID {
 [Key: string]: Comment;
}

export interface Comment {
  id: string;
  author: string;
  comment: string;
}

export interface Posts {
  byId: PostsByID;
  allIds: string[];
}

export interface PostsByID {
 [Key: string]: Post;
}

export interface Post {
  id: string;
  author: string;
  body: string;
  comments: string[];
}

export interface Users {
  byId: UsersByID;
  allIds: string[];
}

export interface UsersByID {
 [Key: string]: User;
}

export interface User {
  username: string;
  name: string;
}

byIdの型定義の方法が分からず調べたことがこの記事のモチベーションになっています。(達成感)

reducerでbyIdの操作

ポイントになる部分のみ紹介します。

javascriptだけで書けないこともないのですが、Reduxの公式ページでもlodashのmergeを最初に紹介しているので、私は出来るだけlodashを使う書き方に寄せています。

Redux公式ページではいろんなライブラリーを使った書き方が紹介されていたり、immutable.jsを使うことの功罪など詳しく書かれています。ぜひそちらも読んでいただいて、私の以下のコードはあくまで1つの参考になれば幸いです。

配列 → byIdの変換

lodashを使う例


const byId = _.keyBy(comments, "id");

javascriptだけの例


// acc = accumulator
const byId = comments.reduce(
  (acc, comment) => ({ ...acc, [comment.id]: comment }),
  {}
);

この例だと読みやすさはlodashの圧勝ですね。

あるpostのbodyの編集

lodashを使う例


const updateBody = (state: Posts, id: string, body: string): Posts => {
  return {
    ...state,
    byId: _.mapValues(state.byId, post => {
      if (post.id !== id) { return post; }
      return { ...post, body };
    })
  };
};

javascriptだけの例


const updateBody = (state: Posts, id: string, body: string): Posts => {
  return {
    ...state,
    byId: {
      ...state.byId,
      [id]: { ...state.byId[id], body },
    },
  };
};

レコード1件だけの更新だとjavascriptだけの書き方が読みやすそうです。
lodashのmapValuesを使う書き方だと、全件更新するケースでも書き方はあまり変わりません。

NormalizingパターンをTypeScriptで書く際のTipsは以上です。
どなたかの参考になれば幸いです。

3
1
0

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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?