JavaScript
reactjs
immutable-js
redux
normalizr
ReduxDay 23

redux, immutable.js, reselect, normalizrを連携させる

More than 1 year has passed since last update.

Redux Advent Calendar 2017の23日目の記事です。

reduxのstoreのデータを正規化する時にreselectとnormalizrを使うのが一般的なのですが、immutable.jsでmodelを作成してるパターンの解説記事がなかったので書きます。

時間がなく疑似コード、省略ありで書きますので、動く保証はしません。


非正規化されてるarticleParamsデータ

{

id: "123",
author: {
id: "1",
name: "Paul"
},
title: "My awesome blog post",
comments: [
{
id: "324",
commenter: {
id: "2",
name: "Nicole"
}
}
]
}


正規化されてるarticleParamsデータ

{

"articles": {
"123": {
id: "123",
author: "1",
title: "My awesome blog post",
comments: [ "324" ]
}
},
"users": {
"1": { "id": "1", "name": "Paul" },
"2": { "id": "2", "name": "Nicole" }
},
"comments": {
"324": { id: "324", "commenter": "2" }
}
}


疑似コード


component

store.dispatch(initArticle(articleParams))


componentなどでactionを実行する(今回は非同期処理など面倒なところは省くが、本番だとほぼ非同期でとってきたデータが正規化される)


schema

import { normalize, schema } from 'normalizr';

// Define a users schema
const userSchema = new schema.Entity('users');

// Define your comments schema
const commentSchema = new schema.Entity('comments', {
commenter: user
});

// Define your article
const articleSchema = new schema.Entity('articles', {
author: user,
comments: [ comment ]
});



actions

export function initArticle(articleParams) {

return {
type: 'article/initialize',
meta: {
schema: ArticleSchema,
},
payload: {
...article,
},
};
}


middleware

import { normalize } from 'normalizr';

export function normalizrMiddleware(): {
return (store) => (next) => (action) => {
const schema = action.meta && action.meta.schema;

if (schema && action.payload && !action.error) {
const normalized = normalize(action.payload, schema);
action = { ...action, payload: normalized };
}

return next(action);
};
}


middlewareでschemaとpayloadがあれば正規化する。reducerはentitiesに正規化したデータを入れて、resultをarticleのreducerで保存しておく。


reducers/EntityReducer.js

import { fromJS, Map } from 'immutable';

import { schemas } from '../schemas';

const hash = {};

Object.keys(schemas).forEach((key) => {
hash[key] = Map({});
});

const INITIAL_STATE = fromJS(hash);

export function entities(state = INITIAL_STATE, action) {
if (action.payload && action.payload.entities) {
let newMap = Map();
Object.entries(action.payload.entities).forEach(([modelName, modelMap]) => {
let newModelMap = Map({});
Object.entries(modelMap).forEach(([key, model]) => {
newModelMap = newModelMap.set(parseInt(key, 10), model);
});
newMap = newMap.set(modelName, newModelMap);
});
return state.mergeDeep(newMap);
}
return state;
}


payload.entitiesにあるものは全て正規化されてる前提で保存していきます。


reducers/ArticleReducer.js

const INITIAL_STATE = {

articleId: 0,
};

export function articleReducer(state = INITIAL_STATE, action) {
switch (action.type) {
case 'article/initialize':
return {...state, articleId: action.payload.result };
default:
return state;
}
}



container


const mapStateToProps = () => {
const getArticle = makeGetArticle();
return (state) => {
return {
article: getArticle(state),
};
};
};

const mapDispatchToProps = () => {
return {};
};

export default connect(
mapStateToProps,
mapDispatchToProps,
)(Component);


ここがポイントでstate.entitiesの段階では正規化されたplain objectだったが、getArticleで渡される値はimmutable.jsのmodel。reselectで合成する。


models/Article.js

import { Record } from 'immutable';

const ArticleRecord = new Record({
id: null,
author: null,
title: true,
comments: [],
});

export default class Article extends ArticleRecord {
static fromJS(article = {}) {
const comments = (article.comments || []).map((comment) => {
return Comment.fromJS(comment);
});
return (new this(article)).merge({
comments: comments,
author: new User(article.author),
});
}
}



selector


const getArticlesMap = (state) => state.entities.get('articles');
const getUsersMap = (state) => state.entities.get('users');
const getCommentsMap = (state) => state.entities.get('comments');
const getArticleId = (state) => state.articleReducer.articleId;

export const makeGetArticle = () => {
return createSelector(
getArticleId,
getArticlesMap,
getCommentsMap,
getUsersMap,
(articleId, articlesMap, commentsMap, usersMap) => {
const article = articlesMap.get(articleId)
return Article.FROM_JS({
...article,
comments: article.comments.map((id) => commentsMap(id)),
author: usersMap(article.author),
});
});
};



Q&A



  • 変更はどうするの?


    • 基本はサーバー通信時にentitiesを書き換え

    • ユーザー入力はredux-formやfinal-formを使って別管理

    • ローカルのタブ選択などは正規化しなくて良い




  • それ以外でどうしてもentitiesを書き換えたいときは?


    • 個別に実行していくしかないですかね。。ベストプラクティスを逆に知りたい。




Appendix

Reduxのreselectとは

reduxのstateを正規化してCQRSの恩恵を受ける土台を作る