Managing Normalized Data

As mentioned in Normalizing State Shape, the Normalizr library is frequently used to transform nested response data into a normalized shape suitable for integration into the store. However, that doesn't address the issue of executing further updates to that normalized data as it's being used elsewhere in the application. There are a variety of different approaches that you can use, based on your own preference. We'll use the example of adding a new Comment to a Post.

Standard Approaches

Simple Merging

One approach is to merge the contents of the action into the existing state. In this case, we need to do a deep recursive merge, not just a shallow copy. The Lodash merge function can handle this for us:

  1. import merge from 'lodash/merge'
  2. function commentsById(state = {}, action) {
  3. switch (action.type) {
  4. default: {
  5. if (action.entities && action.entities.comments) {
  6. return merge({}, state, action.entities.comments.byId)
  7. }
  8. return state
  9. }
  10. }
  11. }

This requires the least amount of work on the reducer side, but does require that the action creator potentially do a fair amount of work to organize the data into the correct shape before the action is dispatched. It also doesn't handle trying to delete an item.

Slice Reducer Composition

If we have a nested tree of slice reducers, each slice reducer will need to know how to respond to this action appropriately. We will need to include all the relevant data in the action. We need to update the correct Post object with the comment's ID, create a new Comment object using that ID as a key, and include the Comment's ID in the list of all Comment IDs. Here's how the pieces for this might fit together:

  1. // actions.js
  2. function addComment(postId, commentText) {
  3. // Generate a unique ID for this comment
  4. const commentId = generateId('comment')
  5. return {
  6. type: 'ADD_COMMENT',
  7. payload: {
  8. postId,
  9. commentId,
  10. commentText
  11. }
  12. }
  13. }
  14. // reducers/posts.js
  15. function addComment(state, action) {
  16. const { payload } = action
  17. const { postId, commentId } = payload
  18. // Look up the correct post, to simplify the rest of the code
  19. const post = state[postId]
  20. return {
  21. ...state,
  22. // Update our Post object with a new "comments" array
  23. [postId]: {
  24. ...post,
  25. comments: post.comments.concat(commentId)
  26. }
  27. }
  28. }
  29. function postsById(state = {}, action) {
  30. switch (action.type) {
  31. case 'ADD_COMMENT':
  32. return addComment(state, action)
  33. default:
  34. return state
  35. }
  36. }
  37. function allPosts(state = [], action) {
  38. // omitted - no work to be done for this example
  39. }
  40. const postsReducer = combineReducers({
  41. byId: postsById,
  42. allIds: allPosts
  43. })
  44. // reducers/comments.js
  45. function addCommentEntry(state, action) {
  46. const { payload } = action
  47. const { commentId, commentText } = payload
  48. // Create our new Comment object
  49. const comment = { id: commentId, text: commentText }
  50. // Insert the new Comment object into the updated lookup table
  51. return {
  52. ...state,
  53. [commentId]: comment
  54. }
  55. }
  56. function commentsById(state = {}, action) {
  57. switch (action.type) {
  58. case 'ADD_COMMENT':
  59. return addCommentEntry(state, action)
  60. default:
  61. return state
  62. }
  63. }
  64. function addCommentId(state, action) {
  65. const { payload } = action
  66. const { commentId } = payload
  67. // Just append the new Comment's ID to the list of all IDs
  68. return state.concat(commentId)
  69. }
  70. function allComments(state = [], action) {
  71. switch (action.type) {
  72. case 'ADD_COMMENT':
  73. return addCommentId(state, action)
  74. default:
  75. return state
  76. }
  77. }
  78. const commentsReducer = combineReducers({
  79. byId: commentsById,
  80. allIds: allComments
  81. })

The example is a bit long, because it's showing how all the different slice reducers and case reducers fit together. Note the delegation involved here. The postsById slice reducer delegates the work for this case to addComment, which inserts the new Comment's ID into the correct Post item. Meanwhile, both the commentsById and allComments slice reducers have their own case reducers, which update the Comments lookup table and list of all Comment IDs appropriately.

Other Approaches

Task-Based Updates

Since reducers are just functions, there's an infinite number of ways to split up this logic. While using slice reducers is the most common, it's also possible to organize behavior in a more task-oriented structure. Because this will often involve more nested updates, you may want to use an immutable update utility library like dot-prop-immutable or object-path-immutable to simplify the update statements. Here's an example of what that might look like:

  1. import posts from "./postsReducer";
  2. import comments from "./commentsReducer";
  3. import dotProp from "dot-prop-immutable";
  4. import {combineReducers} from "redux";
  5. import reduceReducers from "reduce-reducers";
  6. const combinedReducer = combineReducers({
  7. posts,
  8. comments
  9. });
  10. function addComment(state, action) {
  11. const {payload} = action;
  12. const {postId, commentId, commentText} = payload;
  13. // State here is the entire combined state
  14. const updatedWithPostState = dotProp.set(
  15. state,
  16. `posts.byId.${postId}.comments`,
  17. comments => comments.concat(commentId)
  18. );
  19. const updatedWithCommentsTable = dotProp.set(
  20. updatedWithPostState,
  21. `comments.byId.${commentId}`,
  22. {id : commentId, text : commentText}
  23. );
  24. const updatedWithCommentsList = dotProp.set(
  25. updatedWithCommentsTable,
  26. `comments.allIds`,
  27. allIds => allIds.concat(commentId);
  28. );
  29. return updatedWithCommentsList;
  30. }
  31. const featureReducers = createReducer({}, {
  32. ADD_COMMENT : addComment,
  33. };
  34. const rootReducer = reduceReducers(
  35. combinedReducer,
  36. featureReducers
  37. );

This approach makes it very clear what's happening for the "ADD_COMMENTS" case, but it does require nested updating logic, and some specific knowledge of the state tree shape. Depending on how you want to compose your reducer logic, this may or may not be desired.

Redux-ORM

The Redux-ORM library provides a very useful abstraction layer for managing normalized data in a Redux store. It allows you to declare Model classes and define relations between them. It can then generate the empty "tables" for your data types, act as a specialized selector tool for looking up the data, and perform immutable updates on that data.

There's a couple ways Redux-ORM can be used to perform updates. First, the Redux-ORM docs suggest defining reducer functions on each Model subclass, then including the auto-generated combined reducer function into your store:

  1. // models.js
  2. import { Model, fk, attr, ORM } from 'redux-orm'
  3. export class Post extends Model {
  4. static get fields() {
  5. return {
  6. id: attr(),
  7. name: attr()
  8. }
  9. }
  10. static reducer(action, Post, session) {
  11. switch (action.type) {
  12. case 'CREATE_POST': {
  13. Post.create(action.payload)
  14. break
  15. }
  16. }
  17. }
  18. }
  19. Post.modelName = 'Post'
  20. export class Comment extends Model {
  21. static get fields() {
  22. return {
  23. id: attr(),
  24. text: attr(),
  25. // Define a foreign key relation - one Post can have many Comments
  26. postId: fk({
  27. to: 'Post', // must be the same as Post.modelName
  28. as: 'post', // name for accessor (comment.post)
  29. relatedName: 'comments' // name for backward accessor (post.comments)
  30. })
  31. }
  32. }
  33. static reducer(action, Comment, session) {
  34. switch (action.type) {
  35. case 'ADD_COMMENT': {
  36. Comment.create(action.payload)
  37. break
  38. }
  39. }
  40. }
  41. }
  42. Comment.modelName = 'Comment'
  43. // Create an ORM instance and hook up the Post and Comment models
  44. export const orm = new ORM()
  45. orm.register(Post, Comment)
  46. // main.js
  47. import { createStore, combineReducers } from 'redux'
  48. import { createReducer } from 'redux-orm'
  49. import { orm } from './models'
  50. const rootReducer = combineReducers({
  51. // Insert the auto-generated Redux-ORM reducer. This will
  52. // initialize our model "tables", and hook up the reducer
  53. // logic we defined on each Model subclass
  54. entities: createReducer(orm)
  55. })
  56. // Dispatch an action to create a Post instance
  57. store.dispatch({
  58. type: 'CREATE_POST',
  59. payload: {
  60. id: 1,
  61. name: 'Test Post Please Ignore'
  62. }
  63. })
  64. // Dispatch an action to create a Comment instance as a child of that Post
  65. store.dispatch({
  66. type: 'ADD_COMMENT',
  67. payload: {
  68. id: 123,
  69. text: 'This is a comment',
  70. postId: 1
  71. }
  72. })

The Redux-ORM library maintains relationships between models for you. Updates are by default applied immutably, simplifying the update process.

Another variation on this is to use Redux-ORM as an abstraction layer within a single case reducer:

  1. import { orm } from './models'
  2. // Assume this case reducer is being used in our "entities" slice reducer,
  3. // and we do not have reducers defined on our Redux-ORM Model subclasses
  4. function addComment(entitiesState, action) {
  5. // Start an immutable session
  6. const session = orm.session(entitiesState)
  7. session.Comment.create(action.payload)
  8. // The internal state reference has now changed
  9. return session.state
  10. }

By using the session interface you can now use relationship accessors to directly access referenced models:

  1. const session = orm.session(store.getState().entities)
  2. const comment = session.Comment.first() // Comment instance
  3. const { post } = comment // Post instance
  4. post.comments.filter(c => c.text === 'This is a comment').count() // 1

Overall, Redux-ORM provides a very useful set of abstractions for defining relations between data types, creating the "tables" in our state, retrieving and denormalizing relational data, and applying immutable updates to relational data.