LoginSignup
14
12

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-12-22

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の恩恵を受ける土台を作る

14
12
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
14
12