50
39

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 5 years have passed since last update.

Normalizrを使用したReduxの実装パターン

Last updated at Posted at 2017-09-05

課題

APIなどで取得したデータはたいてい深くネストしていることがおおく、reducerでのデータ更新処理が複雑になりがち。
そこでデータを Normalie(平たく)することで実装しやすくする。

Reduxの公式ドキュメントにも実装パターンの例が載っているが、今回は、Normalizr を使用した場合の実装パターンを見てみる。

使い方

基本的な使い方はREADME参照。

例えばデータが元データがこんな形だった場合、

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

Normalize後は下記のようになる。

{
  results: ["123"],
  entities: {
    "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", text: "Awesome comment", "commenter": "2" }
    }
  }
}

実装例

初期データはAPIから取得することを想定している。
まず、redux-sagaredux-thunk などの Redux-middleware で取得したデータをNormalizeして ARTICLE_FETCH_SUCCESS アクションを発行する。
今回は redux-saga を使用した例。

sagas.js
import { put, takeEvery, all } from 'redux-saga/effects'
import apiClient from './apiClient'
import { normalize } from 'normalizr'
import schemas from './schemas'

export function* fetchArticle(action) {
  const id = action.id
  // APIで取得したデータをNormalizeする
  const response = yield apiClient.get('/article/' + id)
  const article = schemas.article
  const normalizedData = normalize(response.data, [article])
  yield put({ type: 'ARTICLE_FETCH_SUCCESS', payload: normalizedData })
}

export function* watchArticleFetch() {
  yield takeEvery('ARTICLE_FETCH_REQUESTED', fetchArticle)
}

export default function* rootSaga() {
  yield watchArticleFetch()
}

rootReducer ではこのアクションを受け取った場合のみ entities を取得したデータでそのまま置き換えている。
また、combineReducer を使って、 entities とその他のステートをスライスする。
データ取得アクション以外では articlesReducerusersReducercommentsReducer にそれぞれ処理を移譲する。

reducers/rootReducer.js
import { routerReducer } from 'react-router-redux'
import { combineReducers } from 'redux'
import articlesReducer from './articles.js'
import usersReducer from './users.js'
import commentsReducer from './comments.js'

const entities = (state = {}, action) => {
  switch (action.type) {
  case 'ARTICLE_FETCH_SUCCESS': {
    const entities = action.payload.entities
    return entities
  }
  default:
    return {
      ...state,
      articles: articlesReducer(state.articles, action),
      users: usersReducer(state.users, action),
      comments: commentsReducer(state.comments, action),
    }
  }
}
export default combineReducers({
  entities,
  routing: routerReducer
  ... // その他reducer
})

例えばコメントを追加する場合

actionCreator では一意な commentId を生成してReducerに渡す。

action.js
export const addComment = (articleId, commenterId, text) => {
  const commentId = generateId() // ユニークなIDを生成する
  return { type: 'ADD_COMMENT', articleId, commentId, commenterId, text }
}

articlesReducer では、コメントが追加された場合は自身の comments プロパティに commentId を追加するだけでいい。

reducers/articles.js
export default (state = {}, action) => {
  switch (action.type) {
  case 'ADD_COMMENT': {
    const { articleId, commentId } = action
    const article = state[articleId]
    const comments = article.comments.concat(commentId)
    return {
      ...state,
      [articleId]: {
        ...article,
        comments
      }
    }
  }
  default:
    return state
  }
}

その他にも例えば comments の順番を入れ替えたい時は articles.comments のidの並びを入れ替えるだけで良い。

commentsReducer で、comment の実体を生成して state に追加する。

reducers/comments.js
export default (state = {}, action) => {
  switch (action.type) {
  case 'ADD_COMMENT': {
    const { commenterId, commentId, text } = action
    const comment = {
      id: commentId,
      commenter: commenterId,
      text
    }
    return {
      ...state,
      [commentId]: comment
    }
  }
  default:
    return state
  }
}

このようにネストしたデータをNormalizeすることでキレイにReducerを分割することが出来る。

View側の実装

View側ではNormalizeされたままのデータだと使いにくい。
今回は react-reduxmapStateToProps内でNormalizeされたデータをDenormalizeした。
Denormalizeすることで通常通り article.commentscommentの配列にアクセスできる。

containers/Article.js
import React, { Component } from 'react'
import { denormalize } from 'normalizr'
import { connect } from 'react-redux'
import ArticleForm from './ArticleForm/index'
import { fetchArticle } from '../actions'
import schema from '../schemas'

class Article extends Component {
  componentDidMount() {
    this.props.dispatch(newArticle())
  }

  render() {
    if (!this.props.article) {
      return null
    }
    return <ArticleForm article={this.props.article} />
  }
}

const mapStateToProps = (state, ownProps) => {
  if (!state.entities) {
    return {}
  }
  const { params } = ownProps
  const denormalized = denormalize({ article: params.id }, schema, state.entities)
  return {
    article: denormalized.article
  }
}

export default connect(mapStateToProps)(Article)

所感

良い点

  • Reducerの分割がしやすい
  • 各Reducerの実装がシンプルになる
  • それによりテストが書きやすくなる

悪い点

  • データ同士の紐付けがIDだよりになるので毎回一意のIDを生成する必要がある
  • Viewで毎回Denormalizeするのでパフォーマンスが若干気になる
50
39
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
50
39

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?