Actions, Normalizr and maintain relationship - Tuong-Nguyen/Angular-D3-Cometd GitHub Wiki

Flux action standard (FSA):

  • FSA treat errors as a first class concept.
  • A action must contain type properties, may have payload, meta and error.
interface ActionWithPayload<T> extends Action {
  payload?: T;
  meta?: object;
  error?: boolean;
}

See:

Normalizr:

Maintain relationships between entities:

Normalizr does not maintain relationship by itself, it only focus on flatten api response in json format, so we need to handle relationship manually. Don't worry, it's not hard :D

See:

denormalize()

Pros:

  • Create nested model from normalized entities is easy
const model = denormalize([1, 2, 3], [schema], entities);

Cons:

  • Bad performance when denormalize recursive entities tree
  • If we delete tab by using flag status, when buiding dashboard by using denormalize, we need to filter out deleted tab from this dashboard, this is tedious and error-prone.

Also, when using redux, we don't need nested model to passing down to the component tree, as author of redux said:

Let every component receive the data it uses as props, but retrieve the data only children need by using connect() Dan Abramov

So we can create a container to get dashboard when we need a dashboard in our component, create another container to get tabs list when need to show tabs of a dashboard... by doing that, we can avoid some issues caused by denormalize

See:

State shape:

{
  // All entities retrieved from server will be normalized and merged directly into this slice of state
  entities: {
    dashboards: {},
    tabs: {},
    placeholders: {},
    widgets: {}
  },
  widgets: {
    ids: []  // Use to get widgets from entities state
    edit: {
      widget: {}
    }
  },
  dashboards: {
    ids: []  // Use to get dashboards from entities state
    grid: {},
  }
}

CRUD operations on entities:

Suppose we have current state:

{
  "entities": {
    "dashboards": {
      "1": {
        "name": "dashboard 1",
        "tabs": [1]
      },
      "2": {
        "name": "dashboard 2",
        "tabs": [1, 2]
      }
    },
    "tabs": {
      "1": {
        "name": "tab 1",
        "dashboards": [1, 2]
      },
      "2": {
        "name": "tab 2",
        "dashboards": [2]
      }
    }
  }
}

Entity reducer:

Any changes to the entities will be merged directly into the store's state by using lodash's merge function:

if (action.payload && action.payload.entities) {
  const newState = _.merge({}, state, action.payload.entities);
}

_.merge will recursively create new object for each properties in the new state, this cause unnecessary update when we only update a part of our entities state. Merge function from reddit-mobile can solve this problem.

See:

Update ids array:

List of ids will be used to get entities when we need to display a list of dashboards, and this ids need to be updated when load, load all and add dashboard.

const ids = (state = [], action) => {
  if (action.type === 'LOAD_SUCCESS') {
    return _.union(state, action.payload.result);
  }
}

Add:

We want to add a new tab to our state:

{
  "id": 3,
  "name": "tab 3",
  "dashboards": [1]
}

Our state now look like this:

{
  "entities": {
    "dashboards": {
      "1": {
        "name": "dashboard 1",
        "tabs": [1]
      },
      "2": {
        "name": "dashboard 2",
        "tabs": [1, 2]
      }
    },
    "tabs": {
      "1": {
        "name": "tab 1",
        "dashboards": [1, 2]
      },
      "2": {
        "name": "tab 2",
        "dashboards": [2]
      },
      "3": {
        "name": "tab 3",
        "dashboards": [1]
      }
    }
  }
}

Notice that dashboard with id 1 does not update its tabs ids array when the new tab is added to the entities. We have to update its relationship manually in the entity reducer by adding the added tab id to the tabs ids array of dashboard 1

let newState = {...state};
action.payload.result[0].dashboards.forEach(dashboardId => {
  newState = {
    ...state,
    dashboards: {
      ...state.dashboards,
      [dashboardId]: {
        ...state.dashboards[dashboardId],
        tabs: _.union(state.dashboards[dashboardId].tabs, action.payload.result)
      }
    }
  }
});

Delete:

We have 3 ways delete a tab of a dashboard:

  • After delete a tab successfully, make another request to get dashboard from server and update the entire entity state after that. This is the simplest approach, but making another request to server and re-update entire entity state is not good for performance and UX
  • Manually delete a tab, and delete it's id in the tab ids list of all the dashboards contain it. This method make our reducer complicated and can take a lot of effort to implements it because it look like we have to write a ORM database from scratch
  • Prefered When delete a tab, change its status to DELETED, and in the selector, we filter out deleted tab by checking its status. This will solve all the problems introduced by two approaches above.
const newState = {
  ...state,
  tabs: {
    ...state.tabs,
    [action.payload]: {
      ...state.tabs[action.payload],
      status: 'DELETED'
    }
  }
}

See:

Test reducer:

We need to ensure that the slice of state do not change if there are no updates on that state. For example, with entities state, when a tab is added to a dashboard, only state associated with this tab change (dashboards, tabs, placeholders), widgets state should not be changed

Usage

  • For basic data (ex: Dashboard, Widget, ...), create selectors which get data from entities state.
  • For relational data (merged data, ex: placeHolders), create selector which get from selectors of basic data.