Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to cut the boilerplate when updating nested entities? #994

Closed
andre0799 opened this issue Nov 3, 2015 · 32 comments
Closed

How to cut the boilerplate when updating nested entities? #994

andre0799 opened this issue Nov 3, 2015 · 32 comments

Comments

@andre0799
Copy link

So I have this nested structure under my state

state = {
   plans: [
    {title: 'A', exercises: [{title: 'exe1'}, {title: 'exe2'},{title: 'exe3'}]},
    {title: 'B', exercises: [{title: 'exe5'}, {title: 'exe1'},{title: 'exe2'}]}
   ]
}

I'm trying to create a reduce which does not mutate the previous state, but it's getting to a point where I'm spending more time figuring out how to do this then coding the rest of the app, it's getting pretty frustrating.

For example, if I wanted to add a new empty exercise or update an existing one, mutating the data I would just do:

state.plans[planIdx].exercises.push({})

state.plans[planIdx].exercises[exerciseIdx] = exercise

but what could be my best approach to do the same in this nested structure? I've read the Redux docs and also the troubleshooting part, but the furthest I got was updating a plan, where I would do:

case 'UPDATE_PLAN':
    return {
      ...state,
      plans: [
      ...state.plans.slice(0, action.idx),
      Object.assign({}, state.plans[action.idx], action.plan),
      ...state.plans.slice(action.idx + 1)
      ]
    };

Isn't there a faster way to work with this? Even if I have to use external libraries, or at least if someone can explain me how to better deal with this...

Thank you!

@hulufei
Copy link

hulufei commented Nov 3, 2015

It's recommended to normalize nested JSON like: https://github.com/gaearon/normalizr

@gaearon
Copy link
Contributor

gaearon commented Nov 3, 2015

Yes, we suggest normalizing your data.
This way you don't need to go “deep”: all your entities are at the same level.

So your state would look like

{
  entities: {
    plans: {
      1: {title: 'A', exercises: [1, 2, 3]},
      2: {title: 'B', exercises: [5, 1, 2]}
     },
    exercises: {
      1: {title: 'exe1'},
      2: {title: 'exe2'},
      3: {title: 'exe3'}
    }
  },
  currentPlans: [1, 2]
}

Your reducers might look like

import merge from 'lodash/object/merge';

const exercises = (state = {}, action) => {
  switch (action.type) {
  case 'CREATE_EXERCISE':
    return {
      ...state,
      [action.id]: {
        ...action.exercise
      }
    };
  case 'UPDATE_EXERCISE':
    return {
      ...state,
      [action.id]: {
        ...state[action.id],
        ...action.exercise
      }
    };
  default:
    if (action.entities && action.entities.exercises) {
      return merge({}, state, action.entities.exercises);
    }
    return state;
  }
}

const plans = (state = {}, action) => {
  switch (action.type) {
  case 'CREATE_PLAN':
    return {
      ...state,
      [action.id]: {
        ...action.plan
      }
    };
  case 'UPDATE_PLAN':
    return {
      ...state,
      [action.id]: {
        ...state[action.id],
        ...action.plan
      }
    };
  default:
    if (action.entities && action.entities.plans) {
      return merge({}, state, action.entities.plans);
    }
    return state;
  }
}

const entities = combineReducers({
  plans,
  exercises
});

const currentPlans = (state = [], action) {
  switch (action.type) {
  case 'CREATE_PLAN':
    return [...state, action.id];
  default:
    return state;
  }
}

const reducer = combineReducers({
  entities,
  currentPlans
});

So what's going on here? First, note that the state is normalized. We never have entities inside other entities. Instead, they refer to each other by IDs. So whenever some object changes, there is just a single place where it needs to be updated.

Second, notice how we react to CREATE_PLAN by both adding an appropriate entity in the plans reducer and by adding its ID to the currentPlans reducer. This is important. In more complex apps, you may have relationships, e.g. plans reducer can handle ADD_EXERCISE_TO_PLAN in the same way by appending a new ID to the array inside the plan. But if the exercise itself is updated, there is no need for plans reducer to know that, as ID has not changed.

Third, notice that the entities reducers (plans and exercises) have special clauses watching out for action.entities. This is in case we have a server response with “known truth” that we want to update all our entities to reflect. To prepare your data in this way before dispatching an action, you can use normalizr. You can see it used in the “real world” example in Redux repo.

Finally, notice how entities reducers are similar. You might want to write a function to generate those. It's out of scope of my answer—sometimes you want more flexibility, and sometimes you want less boilerplate. You can check out pagination code in “real world” example reducers for an example of generating similar reducers.

Oh, and I used { ...a, ...b } syntax. It's enabled in Babel stage 2 as ES7 proposal. It's called “object spread operator” and equivalent to writing Object.assign({}, a, b).

As for libraries, you can use Lodash (be careful not to mutate though, e.g. merge({}, a, b} is correct but merge(a, b) is not), updeep, react-addons-update or something else. However if you find yourself needing to do deep updates, it probably means your state tree is not flat enough, and that you don't utilize functional composition enough. Even your first example:

case 'UPDATE_PLAN':
  return {
    ...state,
    plans: [
      ...state.plans.slice(0, action.idx),
      Object.assign({}, state.plans[action.idx], action.plan),
      ...state.plans.slice(action.idx + 1)
    ]
  };

can be written as

const plan = (state = {}, action) => {
  switch (action.type) {
  case 'UPDATE_PLAN':
    return Object.assign({}, state, action.plan);
  default:
    return state;
  }
}

const plans = (state = [], action) => {
  if (typeof action.idx === 'undefined') {
    return state;
  }
  return [
    ...state.slice(0, action.idx),
    plan(state[action.idx], action),
    ...state.slice(action.idx + 1)
  ];
};

// somewhere
case 'UPDATE_PLAN':
  return {
    ...state,
    plans: plans(state.plans, action)
  };

@gaearon
Copy link
Contributor

gaearon commented Nov 3, 2015

It would be nice to turn this into a recipe.

@gaearon gaearon reopened this Nov 3, 2015
@gaearon gaearon added the docs label Nov 3, 2015
@gaearon
Copy link
Contributor

gaearon commented Nov 3, 2015

Ideally we want a kanban board example.
It's perfect for nested entities because “lanes” may have “cards” inside them.

@smashercosmo
Copy link

@andre0799 or you could just use Immutable.js ))

@bebraw
Copy link

bebraw commented Nov 3, 2015

Ideally we want a kanban board example.

I wrote one. Perhaps you could fork it and tweak to your liking.

@chicoxyzzy
Copy link
Contributor

Immutable.js is not always a good solution. It recalculates hash of every parent node of state starting from node you changed and that becomes bottleneck in particular cases (this is not very common cases tho). So ideally you should make some benchmarks before integrating Immutable.js into your application.

@gaearon gaearon changed the title What could be my best approach to deal with this situation with my reducers? How to cut the boilerplate when updating nested entities? Nov 3, 2015
@andre0799
Copy link
Author

Thanks @gaearon for your answer, great explanation!

@andre0799
Copy link
Author

So, when you do CREATE_PLAN you should automatically create a default exercise and add to it. How should I handle cases like this? Should I then call 3 actions in a row? CREATE_PLAN, CREATE_EXERCISE, ADD_EXERCISE_TO_PLAN From where should I do these calls, if that is the case?

@gaearon
Copy link
Contributor

gaearon commented Nov 4, 2015

So, when you do CREATE_PLAN you should automatically create a default exercise and add to it. How should I handle cases like this?

While generally I'm in favor of many reducers handling the same action, it can get too complicated for entities with relationships. Indeed, I am suggesting to model these as separate actions.

You can use Redux Thunk middleware to write an action creator that calls them both:

function createPlan(title) {
  return dispatch => {
    const planId = uuid();
    const exerciseId = uuid();

    dispatch({
      type: 'CREATE_EXERCISE',
      id: exerciseId,
      exercise: {
        id: exerciseId,
        title: 'Default'
      }
    });

    dispatch({
      type: 'CREATE_PLAN',
      id: planId,
      plan: {
        id: planId,
        exercises: [exerciseId],
        title
      }
    });
  };
}

Then, if you apply Redux Thunk middleware, you can call it normally:

store.dispatch(createPlan(title));

@mikebarnhardt
Copy link

So let's say I have a post editor on the backend somewhere with such relationships (posts, authors, tags, attachments, etc).

How do I go about displaying currentPosts similar to the currentPlans array of keys? Would I need to map each key in currentPosts to its corresponding object in entities.posts in the mapStateToProps function? How about sorting currentPosts?

Does all this belong in a reducer composition?

I am missing something here...

@kolodny
Copy link
Contributor

kolodny commented Nov 4, 2015

Regarding the original question, I believe React Immutability Helpers were created for that purpose

@gaearon
Copy link
Contributor

gaearon commented Nov 4, 2015

How do I go about displaying currentPosts similar to the currentPlans array of keys? Would I need to map each key in currentPosts to its corresponding object in entities.posts in the mapStateToProps function? How about sorting currentPosts?

This is correct. You'd do everything when retrieving the data. Please consult "shopping cart" and "real world" examples that come with Redux repo.

@mikebarnhardt
Copy link

Thanks, I already started to get an idea after reading Computing Derived Data on the docs.

I will check those examples out again. I probably didn't understand a lot of what was going at first when I read them.

@gaearon
Copy link
Contributor

gaearon commented Nov 4, 2015

@andre0799

Any connect()ed component has dispatch injected as a prop by default.

this.props.dispatch(createPlan(title));

This is a usage question that is unrelated to this thread. It's better to consult examples or to create StackOverflow questions for this.

@baptistemanson
Copy link

I agree with Dan with normalizing the data, and flattening the state structure as much as possible. It may be put as a recipe / best practice in the documentation, as it would have saved me some headaches.

As I made the mistake of having a bit of depth in my state, I made this lib to help retrofitting and managing deep state with Redux: https://github.com/baptistemanson/immutable-path
Maybe I got it all wrong but I would be interested with your feedback. Hope it will help someone.

@ericdfields
Copy link

This was helpful. Thanks to all.

@tmonte
Copy link

tmonte commented Feb 1, 2016

How would you go about adding an exercise to a plan, using the same structure as the real-world example? Let's say that adding an exercise returned the newly created exercise entity, with a planId field. Is it possible to add the new exercise to that plan without having to write a reducer for plans, and listen specifically to a CREATE_EXERCISE action?

@pacjin79
Copy link

Great discussion and information here. I would like to use normalizr for my project but have a question regarding to saving the updated data back to a remote server. Mainly, is there are simple way to revert the normalized shape back to the nested shape provided by the remote api after updating? This is important when the client makes changes and needs to feed it back to the remote api where he has no control over the shape of the update request.

For example: client fetches the nested exercise data -> client normalizes it and stores it in redux -> user makes changes to the normalized data on client side -> user clicks save -> client app transforms the updated normalized data back to the nested form so it can submit it to the remote server -> client submits to server

If I used normalizr would I need to write a custom transformer for the bolded step or is there a library or a helper method you'd recommend for this? Any recommendations would be much appreciated.

thanks

@gaearon
Copy link
Contributor

gaearon commented May 10, 2016

There’s something called https://github.com/gpbl/denormalizr but I’m not sure how closely it tracks normalizr updates. I wrote normalizr in a couple of hours for the app I worked on, you are welcome to fork it and add denormalization 😄 .

@pacjin79
Copy link

Cool I definitely will take a look at denormalization and contribute back to your project once I have something. Great work for a couple of hours ;-) Thanks for getting back to me.

@MathiasKahlen
Copy link

MathiasKahlen commented May 12, 2016

I'm kind of in the same situation with changing deeply nested data, however, I've found a working solution using immutable.js.

Is it alright if I link to a StackOverflow post I made asking for feedback on the solution here?

I'm linking it for now, please do delete my post or say if it's inappropriate to link here:
http://stackoverflow.com/questions/37171203/manipulating-data-in-nested-arrays-in-redux-with-immutable-js

@ariofrio
Copy link

I've seen this approach being recommended in the past. However, this approach seems not to work well when a nested object needs to be removed. In this case, the reducer would need to look through all references to the object and remove them, an operation which would be O(n), before being able to remove the object itself. Anyone ran into a similar problem and solved it?

@markerikson
Copy link
Contributor

@ariofrio : ah... I'm confused. The point of normalization is that the objects are not stored nested, and there's only one reference to a given item, making it easy to update that item. Now, if there's multiple other entities that "referenced" this item by ID, sure, they'd need to be updated as well, same as if things were not normalized.

Is there a specific concern you have, or a troublesome scenario you're dealing with?

@ariofrio
Copy link

Here is what I mean. Say the current state looks like:

{
  entities: {
    plans: {
      1: {title: 'A', exercises: [1, 2, 3]},
      2: {title: 'B', exercises: [5, 6]}
     },
    exercises: {
      1: {title: 'exe1'},
      2: {title: 'exe2'},
      3: {title: 'exe3'}
      5: {title: 'exe5'}
      6: {title: 'exe6'}
    }
  },
  currentPlans: [1, 2]
}

In this example, each exercise can only be referenced by one plan. When the user clicks on "Remove Exercise", the message might look something like this:

{type: "REMOVE_EXERCISE", payload: 2}

But to implement this properly, one would need to iterate over all plans, and then all exercises within each plan, to find the one that referenced the exercise with id 2, in order to avoid a dangling reference. This is the O(n) operation I was worried about.

A way to avoid this is to include the plan id in the payload of REMOVE_EXERCISE, but at this point, I don't see the advantage over using nesting the structures. If we used nested state instead, the state might look like:

{
   plans: [
    {title: 'A', exercises: [{title: 'exe1'}, {title: 'exe2'},{title: 'exe3'}]},
    {title: 'B', exercises: [{title: 'exe5'}, {title: 'exe6'}]}
   ]
}

And the message to remove the exercise might look like:

{type: "REMOVE_EXERCISE", payload: {plan_index: 0, exercise_index: 1}}

@markerikson
Copy link
Contributor

A few thoughts:

  • You could maintain a reverse lookup of exercises to plans to simplify that scenario. Similarly, what the Redux-ORM library does is auto-generate "through tables" for many-type relations. So, in this case you'd have a "PlanExercise" "table" in your store, which would contain {id, planId, exerciseId} triplets. Certainly an O(n) scan, but a straightforward one.
  • An O(n) operation isn't inherently a bad thing. That entirely depends on how big N is, the constant factor in front of the term, how often it happens, and what else is going on in your application. Iterating over a list of 10 or 15 items and doing some equality checks on a user button click is going to be a totally different thing than, say, iterating a list of 10M items coming into the system every 500ms and performing some expensive operation for each item. In this case, odds are that even checking through thousands of plans would not be a significantly meaningful bottleneck.
  • Is this an actual performance concern you're seeing, or just looking ahead to possible theoretical issues?

Ultimately, both nested and normalized state are conventions. There's good reasons to use normalized state with Redux, and there may be good reasons to keep your state normalized. Pick whatever works for you :)

@cage1618
Copy link

my solution like this:

function deepCombinReducer(parentReducer, subReducer) {
    return function (state = parentReducer(void(0) /* get parent reducer initial state */, {}) {
        let finalState = {...state};

        for (var k in subReducer) {
          finalState[k] = subReducer(state[k], action);
        }

       return parentReducer(finalState, action);
    };
}

const parentReducer = function(state = {}, action) {
    return state;
}

const subReducer = function(state = [], action) {
    state = Immutable.fromJS(state).toJS();
    switch(action.type) {
       case 'ADD':
          state.push(action.sub);
           return state;
       default:
          return state;
   }
}

export default combineReducers({
   parent: deepCombinReducer(parentReducer, {
       sub: subReducer
   })
})

Then, you can get the store like this:

{
    parent: {
       sub: []
    }
}

dispatch({
    type: 'ADD',
    sub: '123'
});

// the store will change to:
{
    parent: {
       sub: ['123']
    }
}

@wzup
Copy link

wzup commented Dec 6, 2016

@smashercosmo immutable.js with deep nested state? I'm curious how

@wzup
Copy link

wzup commented Dec 6, 2016

@gaearon

We never have entities inside other entities.

I don't understand it. We have at least three levels of nesting here:

{
  entities: {
    plans: {
      1: {title: 'A', exercises: [1, 2, 3]},
      2: {title: 'B', exercises: [5, 1, 2]}
     },
    exercises: {
      1: {title: 'exe1'},
      2: {title: 'exe2'},
      3: {title: 'exe3'}
    }
  },
  currentPlans: [1, 2]
}

entities.plans[1] - three levels
entities.exercises[1] - three levels

This is a not nested object. Only one level.

{
   plans: [1,2, 3],
   exercises: [1,2,3],
   'so forth': [1,2,3]
}

@markerikson
Copy link
Contributor

@wzup : FYI, Dan doesn't spend much time looking at Redux issues these days - he's got enough on his plate working on React.

The meaning of "nesting" here is when the data itself is nested, like this example from earlier in the thread:

{
   plans: [
    {title: 'A', exercises: [{title: 'exe1'}, {title: 'exe2'},{title: 'exe3'}]},
    {title: 'B', exercises: [{title: 'exe5'}, {title: 'exe6'}]}
   ]
}

In that example, the only way to access exercise "exe6" is to dig into the structure, like plans[1].exercises[2].

@bzuker
Copy link

bzuker commented Dec 17, 2016

I'm interested in @tmonte 's question:

How would you go about adding an exercise to a plan, using the same structure as the real-world example? Let's say that adding an exercise returned the newly created exercise entity, with a planId field. Is it possible to add the new exercise to that plan without having to write a reducer for plans, and listen specifically to a CREATE_EXERCISE action?

When you have many entities, creating one reducer for each entity could be painful, but this approach could solve it. I haven't found a solution for it so far.

@devmarwen
Copy link

I presonnaly use mergeWith instead of merge for more flexibility:

import mergeWith from 'lodash/mergeWith';

// Updates an entity cache in response to any action with `entities`.
function entities(state = {}, action) {
  // Here where we STORE or UPDATE one or many entities
  // So check if the action contains the format we will manage
  // wich is `payload.entities`
  if (action.payload && action.payload.entities) {
    // if the entity is already in the store replace
    // it with the new one and do not merge. Why?
    // Assuming we have this product in the store:
    //
    // products: {
    //   1: {
    //     id: 1,
    //     name: 'Awesome product name',
    //     rateCategory: 1234
    //   }
    // }
    //
    // We will updated with
    // products: {
    //   1: {
    //     id: 1,
    //     name: 'Awesome product name',
    //   }
    // }
    //
    // The result if we were using `lodash/merge`
    // notice the rate `rateCategory` hasn't changed:
    // products: {
    //   1: {
    //     id: 1,
    //     name: 'Awesome product name',
    //     rateCategory: 1234
    //   }
    // }
    // for this particular use case it's safer to use
    // `lodash/mergeWith` and skip the merge
    return mergeWith({}, state, action.payload.entities, (oldD, newD) => {
      if (oldD && oldD.id && oldD.id === newD.id) {
        return newD;
      }
      return undefined;
    });
  }

  // Here you could register other handlers to manipulate 
  // the entities
  switch (action.type) {
    case ActionTypes.SOME_ACTION:
      // do something;
    default:
      return state;
  }
}

const rootReducer = combineReducers({
  entities,
});
export default rootReducer;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests