はじめに
Reduxで深いネストを持つstate管理のベストプラクティスの一つに、データを平坦にして(Normalizing)管理する方法がReduxの公式ページで紹介されています。公式ページではbyId
とallIds
という2つの変数を使って解説されています。
TypeScriptでのbyId
の型定義やreducerの実装で試行錯誤したことを共有します。
対象読者
- Normalizingパターンを知らない人で
- ネストが深いデータを管理していてパフォーマンスが気になる
- データのネストが深くてreducerを書くのがしんどい
- Normalizingパターンを知っているけど
- byIdの型定義方法が分からず、とりあえず
any
にしている - reducerでbyIdの操作が難しいと感じる
- byIdの型定義方法が分からず、とりあえず
Normalizingパターンとは?
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したものをご覧ください。
{
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パターンとは、データをテーブルごとに管理してリレーションを持たせる手法です。より具体的には、byId
はid
をkeyにデータを持っています。またデータに順序を持たせる目的でallIds
がそれぞれのデータのid
のみを配列で持っています。
公式ページではNormalizingによってもたらされる改善点を4つ挙げています。
- データの実体は1つしか存在しないから更新が簡単です。
- ネストが浅いのでreducerを書くのが簡単です。
- データを探すのに必要な情報は、テーブルとIDの2つだけ。
- データはそれぞれ独立しているから、View側で無駄なrenderが走りません。
Normalizingパターンの採用について
データの更新が行われない、またはAPIからのデータ取得によってデータが一括で書き換わりreducerが複雑にならないケースではNormalizingパターンは採用しなくてもいいかもしれません。
公式ページでも以下のような例を挙げているので、いつでもNormalizingパターンを使うべきとは考えていないようです。
{
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は以上です。
どなたかの参考になれば幸いです。