mschipperheyn / normalizr-immutable

Other
123 stars 13 forks source link

Proper way to merge entities #16

Open emroot opened 8 years ago

emroot commented 8 years ago

Hey I'm having some trouble merge entities together, I might be doing something wrong, so wanted to get some feedback here first. My view loads an initial list with basic information, the view is paginated, when I scroll down, we fetch more items from the backend, which should merge the data to the current state. When I click on an individual item, I will show the basic information, fetch more data in the background for this specific item and merge it to the entities. I'm having issue with the merge, when I try to mergeDeep I get collection.update error. Here's a sample of my code below:

// schema.js
import { arrayOf, Schema,  } from 'normalizr-immutable';
import { Map, Record } from 'immutable';

const reducerKey: string = 'myReducer';

/* records */
const Item: Record = new Record({
  users: new Map(),
});

const User: Record = new Record({
  id: undefined,
  fullName: undefined,
});

/* schemas */
export const itemSchema: Schema = new Schema('items', Item, { reducerKey });
export const arrayOfItems: Array = arrayOf(itemSchema);
const userSchema: Schema = new Schema('users', User, { reducerKey });

itemSchema.define({
  users: arrayOf(userSchema),
});

// actions.js
import { arrayOf, normalize } from 'normalizr-immutable';

export function fetchItem() {
  return (dispatch: Function, getState: Function) => {
    dispatch({ type: FETCH_ITEM_REQUESTED })
    Api.getItems()
      .then(payload => {
        dispatch({ 
          type: FETCH_ITEM_SUCCEEDED, 
          payload: normalize(payload.item, itemSchema, {
            getState,
            useMapsForEntityObjects: true,
            useProxyForResults: true
          })
        });
      })
      .catch(err => dispatch({ type: FETCH_ITEM_FAILED, error: true, payload: err }));
  };
}

export function fetchItems() {
  return (dispatch: Function, getState: Function) => {
    dispatch({ type: FETCH_ITEMS_REQUESTED })
    Api.getItems()
      .then(payload => {
        dispatch({ 
          type: FETCH_ITEMS_SUCCEEDED, 
          payload: normalize(payload.items, arrayOfItems, {
            getState,
            useMapsForEntityObjects: true,
            useProxyForResults: true
          })
        });
      })
      .catch(err => dispatch({ type: FETCH_ITEMS_FAILED, error: true, payload: err }));
  };
}

// reducer.js
import { NormalizedRecord } from 'normalizr-immutable';
const initialState: NormalizedRecord = new NormalizedRecord({});

export default function reducer(state: Object = initialState, action: Object) {
  const { payload, type } = action;
  switch (type) {
    case FETCH_ITEM: // Load just one item
      return state.mergeDeepIn(['entities'], payload.get('entities'));
    case PAGINATE: // On paginate add more 
      return state.mergeDeep(payload);
    case FETCH_ITEMS_SUCCEEDED: // Initial fetch
      return state.merge(payload);
    default:
      return state;
}
mschipperheyn commented 7 years ago

Ok, first things first. You should use the latest beta and take it from there.

Secondly, if you deepmerge a normalized entity you need to avoid that the merger function tries to deep merge a proxy reference (e.g. you have a structure like so:

 posts:{
    "1 :{
        id:1
        group: Proxy(15)
    }
 },
 groups:{
     "15":{
         label: "bla",
         posts:[Proxy(1)]
    }
 }

Trying to deepmerge a proxy fails because you will "lose" your Record definition in the process and it gets turned into a Map and you will get all sort of other "strange errors".

So, what I do to avoid this is use a little property I added to the Proxy in the latest beta and I use this as a merge function:

/**
 * merger - Used as a merger function for deep merging immutable objects
 * If a collection is encountered, it will replace the old collection with the new collection because default behaviour is just adding
 * If a proxy is encountered it will replace the old proxy with the new proxy
 *
 * This means that in both cases the references change but the content may be the same
 *
 * @param  {type} a description
 * @param  {type} b description
 * @return {type}   description
 */
export function merger(a, b) {
  if (a && a.mergeWith && !isList(a) && !isList(b) && !isSet(a) && !isSet(b) && !a._isProxy) {
    return a.mergeDeepWith(merger, b)
  }
  return b
}

Thirdly, if you merge proxied entities and you "touch" the proxy before it is merged into the entity structure, e.g. sorting, you will get an error, because you will touch properties through the proxy that are not in the store yet. So, I merge my entities first and then proceed to process the result. Like so:

/* action code */

const json = await response.json();

const normalized = normalizeJson(json.posts.items, arrayOf(postSchema), getState);

dispatch(shared.actionCreatorForMerge(REDUCER)(normalized.entities));

dispatch(processPosts({
    result:normalized.result,
    resultCount:json.posts.resultCount
  }));

/* shared reducer */
export function mergeEntitiesState(reducerState, payload){
  return reducerState.set('entities',reducerState.get('entities').withMutations(state => {
      //we need to make sure the root keys in the payload are there
      payload.forEach((mp, schema) => {

        if(!state.has(schema))
          state = state.set(schema, new Map());

        mp.forEach((entity, id) => {
          //Bug https://github.com/facebook/immutable-js/issues/975 entity gets converted to Record with mergeIn. setIn works
          state = state.setIn([schema, id],entity);
        });

      });

      return state;
    })
  );
    // set('entities',reducerState.get('entities').mergeDeepWith(merger, payload));
}

export function actionTypeForMerge(name){
  return name.toUpperCase() + '_MERGE_ENTITIES';
}

export function actionCreatorForMerge(name){

  return function mergeEntities(payload){
    return {
      type: actionTypeForMerge(name),
      payload
    }
  }
}

/* reducer */

export default function reducer(state = initialState, action = {}) {

switch (action.type) {

  case shared.actionTypeForMerge(REDUCER):
    return shared.mergeEntitiesState(state, action.payload);

[...]

HTH