Search code examples
javascripttypescriptreduxngrxngrx-store

Redux how to update nested state object immutably without useless shallow copies?


Im using NGRX store on angular project.

This is the state type:

export interface TagsMap {
    [key:number]: { // lets call this key - user id.
        [key: number]: number // and this key - tag id.
    }
}

So for example:

{5: {1: 1, 2: 2}, 6: {3: 3}}

User 5 has tags: 1,2, and user 6 has tag 3.

I have above 200k keys in this state, and would like to make the updates as efficient as possible. The operation is to add a tag to all the users in the state. I tried the best practice approach like so :

const addTagToAllUsers = (state, tagIdToAdd) => {
  const userIds = Object.keys(state.userTags);
  return userIds.reduce((acc, contactId, index) => {
    return {
      ...acc,
      [contactId]: {
        ...acc[contactId],
        [tagIdToAdd]: tagIdToAdd
      }
    };
  }, state.userTags);
};

But unfortunately this makes the browser crush when there are over 200k users and around 5 tags each.

I managed to make it work with this:

const addTagToAllUsers = (state, tagIdToAdd) => {
  const stateUserTagsShallowCopy = {...state.userTags};
  const userIds = Object.keys(stateUserTags);
  for (let i = 0; i <= userIds.length - 1; i++) {
    const currUserId = userIds[i];
    stateUserTagsShallowCopy[currUserId] = {
      ...stateUserTagsShallowCopy[currUserId],
      [tagIdToAdd]: tagIdToAdd
    };
  }
  return stateUserTagsShallowCopy;
};

And the components are updated from the store nicely without any bugs as far as I checked.

But as written here: Redux website mentions :

The key to updating nested data is that every level of nesting must be copied and updated appropriately

Therefore I wonder if my solution is bad.

Questions:

  1. I Believe I'm still shallow coping all levels in state, am i wrong ?

  2. Is it a bad solution? if so what bugs may it produce that I might be missing ?

  3. Why is it required to update sub nested level state in an immutable manner, if the store selector will still fire because the parent reference indeed changed. (Since it works with shallow checks on the top level.)

  4. What is the best efficient solution ?

Regarding question 3, here is an example of the selector code :

import {createFeatureSelector, createSelector, select} from '@ngrx/store';//ngrx version 10.0.0

//The reducer
const reducers = {
  userTags: (state, action) => {
    //the reducer function..
  }
}

//then on app module: 
StoreModule.forRoot(reducers)

//The selector :
const stateToUserTags = createSelector(
  createFeatureSelector('userTags'),
  (userTags) => {
    //this will execute whenever userTags state is updated, as long as it passes the shallow check comparison.
    //hence the question why is it required to return a new reference to every nested level object of the state...
    return userTags;
  }
)

//this.store is NGRX: Store<State>
const tags$: Observable<any> = this.store.pipe(select(stateToUser))


//then in component I use it something like this: 
<tagsList tags=[tags$ | async]>
</tagsList>

Solution

  • Your solution is perfectly fine. The rule of thumb is that you cannot mutate an object/array stored in state.

    In Your example, the only thing that You are mutating is the stateUserTagsShallowCopy object (it is not stored inside state since it is a shallow copy of state.userTags).

    Sidenote: It is better to use for of here since you don't need to access the index

    const addTagToAllUsers = (state, tagIdToAdd) => {
      const stateUserTagsShallowCopy = {...state.userTags};
      const userIds = Object.keys(stateUserTags);
      for (let currUserId of userIds) {
        stateUserTagsShallowCopy[currUserId] = {
          ...stateUserTagsShallowCopy[currUserId],
          [tagIdToAdd]: tagIdToAdd
        };
      }
      return stateUserTagsShallowCopy;
    };
    

    If you decide to use immer this will look like this

    import produce from "immer";
    
    const addTagToAllUsers = (state, tagIdToAdd) => {
      const updatedStateUserTags = produce(state.userTags, draft => {
        for (let draftTags of Object.values(draft)) {
           tags[tagIdToAdd] = tagIdToAdd
        }
      })
      return updatedStateUserTags
    });
    

    (this comes usually with performance cost). With immer you can sacrifice performance to gain readability

    ad 3.

    Why is it required to update sub nested level state in an immutable manner, if the store selector will still fire because the parent reference indeed changed. (Since it works with shallow checks on the top level.)

    Every store change selectors recompute to see if the dependent component should re-render.

    imagine that instead of immutable update of the user tags we decided to mutate tags inside user (state.userTags is a new object reference but we mutate (reuse) old entries objects state.userTags[userId])

    const addTagToAllUsers = (state, tagIdToAdd) => {
      const stateUserTagsShallowCopy = {...state.userTags};
      const userIds = Object.keys(stateUserTags);
      for (let currUserId of userIds) {
        stateUserTagsShallowCopy[currUserId][tagIdToAdd] = tagIdToAdd;
      }
      return stateUserTagsShallowCopy;
    };
    

    In your case, you have a selector that takes out state.userTags. It means that every time a state update happens nrgx will compare the previous result of the selector and the current one (prevUserTags === currUserTags by reference). In our case, we change state.userTags so the component that uses this selector will be refreshed with new userTags.

    But imagine other selectors that instead of all userTags will select only one user tags. In our imaginary situation, we mutate directly userTags[someUserId] so the reference remains the same each time. The negative effect here is that subscribing component will be not refreshed (will not see an update after a tag is added).