課題
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-sagaや redux-thunk などの Redux-middleware で取得したデータをNormalizeして ARTICLE_FETCH_SUCCESS
アクションを発行する。
今回は redux-saga
を使用した例。
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
とその他のステートをスライスする。
データ取得アクション以外では articlesReducer
、usersReducer
、commentsReducer
にそれぞれ処理を移譲する。
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に渡す。
export const addComment = (articleId, commenterId, text) => {
const commentId = generateId() // ユニークなIDを生成する
return { type: 'ADD_COMMENT', articleId, commentId, commenterId, text }
}
articlesReducer
では、コメントが追加された場合は自身の comments
プロパティに commentId
を追加するだけでいい。
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
に追加する。
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-redux
の mapStateToProps
内でNormalizeされたデータをDenormalizeした。
Denormalizeすることで通常通り article.comments
でcomment
の配列にアクセスできる。
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するのでパフォーマンスが若干気になる