@cloudflare/redux-grim

1.2.0 • Public • Published

GRiM (Generate Redux in Memory)

Table of contents

What is GRiM?

GRiM is small library to reduce the boiler plate involved in making Redux action creators, and reducers. It's specifically intended for actions which retrieve remote restful data, and provides normalization support.

Action creators and reducers can be created with a single line of code. E.g.

export fetchItem = makeActionCreator('item', 'get', '/parent/(parentId)/item/(itemId)'); 
export itemReducer = makeReducer('item');

Background

We had a lot Redux code at Cloudflare, the bulk of which of which was nearly identical. Very little of it was hand written - most was generated from a simple block of json and the resultant actions and reducers etc. were written out. (This was GRiM's predecessor Grod - Generate Redux on Disk). The theory initially was that people might want to manually edit the generated code, but this never came to passed, and there were a few disadvantages:

  • any changes to the generator would result in a vast number of files being changed
  • having to manually run the process to generate the code was awkward.

It was a perfectly workable solution, one that saved a lot of time, but that that no-one was entirely happy with.

Here's a truncated example of the sort of code that would be generated:

// CRUD actions for 'item'

import * as ActionTypes from '../constants/ActionTypes';
import * as api from '../api/itemApi';

function createItemRequest() {
  return {
    type: ActionTypes.CREATE_ITEM
  };
}

function createItemSuccess(response) {
  return {
    type: ActionTypes.CREATE_ITEM_SUCCESS,
    result: response.body.result,
    reduxKey: 'item',
    method: 'POST'
  };
}

function createItemFailure(error) {
  return {
    type: ActionTypes.CREATE_ITEM_FAILURE,
    errors: error.body && error.body.errors
  };
}

export function createItem(parentId, item, callback, options) {
  return function(dispatch) {
    dispatch(createItemRequest());
    return api.createItem(parentId, item, (error, response) => {
      if (response) {
        dispatch(createItemSuccess(response));
        if (callback) {
          callback(null, response.body.result);
        }
      } else {
        dispatch(createItemFailure(error));
        if (callback) {
          callback(error.body && error.body.errors);
        }
      }
    }, options);
  };
}

//
// For the sake of brevity, the nearly identical methods for GET, PUT, PATCH 
// and DELETE haved been omitted.
//
//  Reducer for 'item'

import * as ActionTypes from '../constants/ActionTypes';
import { static as Immutable } from 'seamless-immutable';

const initialState = Immutable.from({
  data: undefined,
  errors: null,
  isRequesting: false,
  isErrored: false
});

export default function itemReducer(state = initialState, action) {
  switch (action.type) {
    case ActionTypes.CREATE_ITEM:
    case ActionTypes.SAVE_ITEM:
    case ActionTypes.PATCH_ITEM:
    case ActionTypes.GET_ITEM:
    case ActionTypes.DELETE_ITEM:
      return Immutable.merge(state, {
        isRequesting: true,
        isErrored: false,
        errors: null
      });

    case ActionTypes.CREATE_ITEM_SUCCESS:
    case ActionTypes.SAVE_ITEM_SUCCESS:
    case ActionTypes.PATCH_ITEM_SUCCESS:
    case ActionTypes.GET_ITEM_SUCCESS:
    case ActionTypes.DELETE_ITEM_SUCCESS:
      return Immutable.merge(state, {
        data: action.result,
        isRequesting: false,
        isErrored: false
      });

    case ActionTypes.CREATE_ITEM_FAILURE:
    case ActionTypes.SAVE_ITEM_FAILURE:
    case ActionTypes.PATCH_ITEM_FAILURE:
    case ActionTypes.GET_ITEM_FAILURE:
    case ActionTypes.DELETE_ITEM_FAILURE:
      return Immutable.merge(state, {
        isRequesting: false,
        isErrored: true,
        errors: action.errors
      });

    case ActionTypes.RATELIMIT_BOOTSTRAP:
      return Immutable.merge(state, {
        data: action.result,
        isRequesting: false,
        isErrored: false,
        errors: null
      });

    case ActionTypes.RESET_TO_INITIAL_STATE:
      const { includes, excludes } = action;
      if (includes) {
        if (includes.indexOf('item') !== -1) {
          return initialState;
        }
      } else if (!excludes || excludes.indexOf('item') === -1) {
        return initialState;
      }

    default:
      return state;
  }
}


// Api functions:

import * as http from 'cf-util-http';

export function createItem(parentId, item, callback, options = {}) {
  const apiOptions = Object.assign({}, {
    body: item
  }, options);
  http.post('/parent/' + parentId + '/rate_limits', apiOptions, callback);
}

export function saveItem(parentId, item, callback, options = {}) {
  const apiOptions = Object.assign({}, {
    body: item
  }, options);
  http.put('/parent/' + parentId + '/item/' + item.id, apiOptions, callback);
}

export function patchItem(parentId, item, callback, options = {}) {
  const apiOptions = Object.assign({}, {
    body: item
  }, options);
  http.patch('/parent/' + parentId + '/item/' + item.id, apiOptions, callback);
}

export function fetchItem(parentId, itemId, callback, options = {}) {
  const apiOptions = Object.assign({}, null, options);
  http.get('/parent/' + parentId + '/item/' + itemId, apiOptions, callback);
}

export function deleteItem(parentId, item, callback, options = {}) {
  const apiOptions = Object.assign({}, null, options);
  http.del('/parent/' + parentId + '/item/' + item.id, apiOptions, callback);
}

And this is what we have now with GRiM:

// Crud actions for 'item'
export fetchItem = makeActionCreator('item', 'get', '/parent/(parentId)/item/(itemId)');
export createItem = makeActionCreator('item', 'post', '/parent/(parentId)/item');
export saveItem =  makeActionCreator('item', 'put', '/parent/(parentId)/item/[item.id]');
export patchItem =  makeActionCreator('item', 'patch', '/parent/(parentId)/item/[item.id]');
export deleteItem =  makeActionCreator('item', 'delete', '/parent/(parentId)/item/[item.id]');

// Reducer for 'item' 
export itemReducer = makeReducer('item');

It's important to note that although the boilerplate has been abstracted away, GRiM's action creators still produce normal Redux actions, in Flux Standard Action format.

Action lifecycle

As you can see from the original generated code above, executing one of GRiM's action creators begins a sequence of events that will produce two of three possible actions.

  • A start action is dispatched
  • An http request is made for a remote resource
  • If the request succeeds a success event is dispatched
  • if the request fails, an error event is dispatched
const getThing = makeActionCreator('thing', 'get', '/thing');

const action = getThing();

// Start action
{
  type: 'thing.start'
  meta: {
    entityType: 'thing',
    method: 'get' 
  }
}

// Start action if request to /thing succeeds
{
  type: 'thing.success',
  payload: {}, // Api response
  meta: {
    entityType: 'thing',
    method: 'get' 
  }
}

// Error action if request to /thing errors
{
  type: 'thing.error',
  payload: {}, // Api Error response 
  error: true,
  meta: {
    entityType: 'thing',
    method: 'get' 
  }
}

About action types

From the example actions above, you can see that the type property of an action depends solely on the type parameter that was passed to makeActionCreator and the phase of the action (e.g. 'start', 'success' or 'error').

This means that if several action creators share the same key, they will generate the action types, regardless of whether they are GET, POST, PUT etc. requests. The method is added to the meta property of action and actions and reducers can be further customized (see below)

This works well for the restful API's we use at Cloudflare which in which POST, PUT and PATCH methods all return the full representation of the object. If you're not using using a restful API, GRiM may not be the best tool for you.

Usage

GRiM exposes two functions, makeActionCreator and makeReducer

makeActionCreator

const action = makeActionCreator(entityType, method, templateUrl, options)

makeActionCreator is used to generate asynchronous restful actions.

Parameters:

  • entityType: used to generate action types, and indicate the type of data that the url returns
  • method: the http method (lowercase). E.g. 'get', 'put', 'post' etc
  • templateUrl: a string which indicates the url used to fetch the data, and also the fields which will be mapped from the action's parameters. See below.
  • options: options are passed to the various hooks (see below) and can be used to enable debugging information

templateUrls, methods, and action creator parameters

The named parameters are those required by an action creator. They're derived from the template url and the http method. If you pass { debug: true } in the options, makeActionCreator will log the named parameters the action expects.

A get method for basic template url results in an action which has no named parameters.

const action = makeActionCreator('item', 'get', '/foo/bar');
action();

// get request is made to '/foo/bar';

A get method for template url in which parts are delineated by parentheses results in an action where the contents of each set of parentheses are added to the named parameters, and are replaced when the action is called.

const action = makeActionCreator('item', 'get', '/foo/(fooId)/bar/(barId)');
action('abc', 123);

// get request is made to '/foo/abc/bar/123'

The contents of the parentheses are evaluated and replaced

const action = makeActionCreator('item', 'get',
'/foo/(item.parentId)/bar/(item.id)');
action({ parentId: 'abc', id: 123 });

// get request is made to '/foo/abc/bar/123'

For put, patch, and post methods, the previous rules apply, except an additional parameter is required. This is a javascript object which will be sent as a the request body. The body is always the last of the named parameters.

const action = makeActionCreator('item', 'post', '/foo/bar');
action({ value: 1 });

// { value: 1 } is the body posted to '/foo/bar';
const action = makeActionCreator('item', 'post', '/foo/(fooId)/bar/(barId)');
action('abc', 123, {value: 2});

// { value: 2 } is the body posted to '/foo/abc/bar/123'

For methods that require a body, [] syntax can also be used to substitute a value from the object being sent as the body.

const action = makeActionCreator('item', 'patch', '/foo/(fooId)/bar/[id]');
action('abc', { id: 123 });

// { id: 123 } is posted to '/foo/abc/bar/123'

Further action configuration

Actions, as well as being functions, also have methods which allow additional, optional, configuration.

The available methods are:

  • apiFetch
  • on
apiFetch

By default the fetch api is used to make http requests. apiFetch can be used to change or modify this behaviour. Given the limited browser support for fetch, this is recommended.

const action = makeActionCreator('item', 'get', '/item').apiFetch(
  (method, url, body, ...restArgs) => Promise;  
)

// restArgs is array of any parameters that were passed to the action, beyond
// thme named parameters.
on(phase, (action, ...)) => action)

on is used to hook into action creation and enables actions to be customised. It requires two parameters:

  • phase: one of four values corresponding to the action that's been created - start, success, error and all.
  • function: the function that will be executed. The parameters passed to the function depend on the action that's been created

All functions passed to on must return an action object.

on('start', (startAction, namedParams, restArgs, options) => action)

  • startAction - the start action that's just been created.
  • namedParams - an object mapping from the templateUrl's named parameters to their values.
  • restArgs - an array of the remaining arguments (those after the named parameters) that were passed to the action.
  • options - the options object that was passed to makeActionCreator

on('success', (successAction, namedParams, restArgs, options, response) => action)

  • successAction - the success action that's just been created.
  • namedParams - an object mapping from the templateUrl's named parameters to their values.
  • restArgs - an array of the remaining arguments (those after the named parameters) that were passed to the action.
  • options - the options object that was passed to makeActionCreator.
  • response - the response from the api call.

on('error', (errorAction, namedParams, restArgs, options, error) => action

  • errorAction - the error action that's just been created.
  • namedParams - an object mapping from the named parameters to their values.
  • restArgs - an array of the remaining arguments (those after the named parameters) that were passed to the action.
  • options - the options object that was passed to makeActionCreator
  • error - the error that was thrown by the api fetch

on('all', (action, namedParams, restArgs, options, [response])

  • action - that's just been created
  • namedParams - an object mapping from the named parameters to their values.
  • restArgs - an array of the arguments that were passed to the action.
  • options - the options object that was passed to makeActionCreator
  • response (optional) - dependant on the phase. Will be undefined for a start action, the response for a success action, and the error for the error action.

Multiple functions can be added for each phase. They will be executed in the order that they were added, and the action that's passed as the first parameter to each is the result of the preceding call.

mock(function | value => payload)

The mock function allows the endpoint request to be bypassed and a predefined response to be returned instead.

If a function passed to mock, when the action is dispatched the function is executed with the same parameters as the action, and the return value is used as the success action payload.

If the function returns undefined, the endpoint request will be made, so that mocking can be performed selectively, depending on the action parameters.

If the function throws an exception, the value thrown is used as the payload of the error action.

If an object or value is passed to mock, it will be used as the success action payload whenever the action is dispatched.

const action = makeAction('item', 'post', '/endpoint/(id)')
  .mock((id, value) => ({ id, value });

action('abcd', 'elephants');

// The action payload is { id: 'abcd', value: 'elephants' }

unmock()

This will clear the function passed to mock.

makeReducer

makeReducer(entityType, options) is used to generate a reducer which process actions created with the same type.

Parameters:

  • entityType: used to determine which action types this reducer will process.
  • options: options are passed to the various hooks. See below

Further reducer configuration

The reducer produced by calling this function also provides additional functions that can be used for configuration. All these functions return the reducer and can be chained.

  • modifyInitialState
  • on
modifyInitialState(state => state)

Change the initial state of the reducer

on(phase, (state, ...)) => state)

Similarly to how action.on is used to modify actions, the reducer equivalent can be used to modify state. The hook functions are called after the reducer has processed the state, and can be used to return a new state.

It requires two parameters:

  • phase: one of four values corresponding to the action that's been created - start, success, error, all and default.
  • function: the function that will be executed. The parameters passed to the function depend on the phase

All functions passed to on must return the state.

on('start', (nextState, prevState, action, options) on('success', (nextState, prevState, action, options) on('error', (nextState, prevState, action, options) on('all', (nextState, prevState, action, options)

  • nextState - the new state that was created after processing the action
  • prevState - the state that existed prior to processing the action.
  • action - the action that was processed by the reducer
  • options - the options object that was passed to makeReducer

start, success, and error are executed when processing the respective actions, all is executed for all of the above.

on('default', (nextState, prevState, action, options)

  • state - the state
  • action - the action that was received by the reducer
  • options - the options object that was passed to makeReducer

The default case is executed when no other case matches the action type.

Multiple functions can be added for each phase. They will be executed in the order that they were added, and the state that's passed as the first parameter to each is the result of the preceding call.

Dependencies

Seamless-immutable

All the reducers created by GRiM use seamless-immutable to create immutable results. This is not bundled with GRiM and must be included in your project. See https://github.com/rtfeldman/seamless-immutable for details.

Redux-thunk

The redux-thunk middleware must be installed in order to process the actions created by GRiM

Normalization

This library also includes an additional set of functions for dealing with normalization with Redux.

Why Normalize?

Normalization ensures that entities are only stored once in the state tree. Individual reducers will record ids (or arrays of ids), rather than complete objects. Combined with React, this means that any change to an individual entity will be reflected wherever than entity is rendered.

Several functions are provided to deal with normalization.

  • getNormalizerMiddleware: A function which creates Redux middleware which normalizes the results of actions created by GRiM
  • normalizationReducer: A reducer which stores normalized entities in the state tree
  • createSelector: Creates memoized selectors which return denormalized entities.

Rules

The rules array specifies which entity types will be normalized. Each entry requires at least an 'entityType' field, and other values describe how they are normalized.

const rules = [
  { entityType: 'normalizedType' },
  { entityType: 'aliasType', to: 'normalizedType' },
  { 
    entityType: 'nestedType',
    nestedProps: {
      prop1: 'normalizedType',
      prop2: 'normalizedType'
    }
  },
  { entityType: 'notIdType', idProp: 'otherId' }
];

An object with only an entityType field indicates that the object will be normalized under that entityType. E.g. state.entities[entityType].

Aliases

If the rule has a to property, the value of that property is used to look up the actual entity type under which items will be normalized. It must resolve to another entity type defined in the rules array. This allows entities controlled by different sets of actions and reducers to be normalized to the same place in the state tree.

Typically it's used when an endpoint returns an array, to ensure that objects in the array are normalized to the same place as individual items.

Note, aliases can't be used with any other configuration options, such as nestedProp or idProp

Normalizing child properties

If the rule value has a nestedProps property, it's value should be an object whose keys denote the child properties to also be normalized. The values indicate which entity type it will be normalized under.

In the example above, nestedType has two properties, 'prop1', and 'prop2', which will be normalized under 'normalizedType'. The process is recursive, so that if 'normalizedType' also had properties to be normalized or denormalized, this will be managed automatically.

Cyclic dependencies aren't handled, so I don't recommend you created any.

Normalizing by properties other than the id

By default, it's assumed that objects will be normalized using their id properties. This can be overridden by specifying a idProp property in the rule configuration. In the example above, nestedType objects are normalized using their name properties.

Functions

getNormalizerMiddleware(rules, callback)

getNormalizerMiddleware creates a Redux middleware function which takes action result properties and, if they are normalized, replaces them with their ids (or arrays of ids). The normalized data is added to the action under an entities property

The callback function is executed when entities are normalized. It's passed the entityType and the original denormalized value. This is used for legacy integration with older code at Cloudflare. It shouldn't be used otherwise.

normalizationReducer(state, action)

The normalizationReducer adds the normalized data created by the middleware to the state tree. It also removes deleted data.

createSelector(rules, entitiesSelector, entityType, selector)

createSelector is a selector factory which simplifies working with normalized entities. It creates memoized selectors which return denormalized objects. The return value is recomputed when relevant parts of the state tree changes, making it suitable for use with PureComponent.

It's passed the following parameters:

  • rules - the rules array which specifies which entities are normalized and how
  • entitiesSelector - a selector function which is passed the state tree and returns the part of the state tree where the normalized data is stored
  • entityType - the type of the entity being selected
  • selector - a selector function which returns the data from the state tree to be denormalized.

The selector function can return either an id, an array of ids, or an object whose property values are ids. Alternatively it can return an object with a data property in one of the previous formats.

Note: Unlike getNormalizerMiddleware and normalizationReducer, createSelector is almost completely independent of GRiM. Almost. With GRiM, api responses are stored under a 'data' property, alongside a few other fields like 'isRequesting', and 'errors'. Rather than insisting the selector to point directly to the data property, I instead first test if the result of the selector is a data property and then denormalize that. In general I strongly recommend that the selectors you pass to createSelector point directly to the data you want to denormalize rather than the containing object that GRiM uses.

Usage:

const rules = {...};
const entitiesSelector = state => state.entities;
const itemSelector = state => state.path.to.the.normalized.item;
const itemEntitySelector = createSelector(rules, entitiesSelector, 'itemEntityType', itemSelector);
const denormalizedItem = itemEntitySelector(state);

Package Sidebar

Install

npm i @cloudflare/redux-grim

Weekly Downloads

835

Version

1.2.0

License

ISC

Unpacked Size

495 kB

Total Files

13

Last publish

Collaborators

  • cf-media-manager
  • jacobbednarz
  • celso
  • cf-radar
  • dash_service_account
  • g4brym
  • snigdha34
  • wrangler-publisher
  • marksteyn
  • chiminator
  • sgoodhew_cf
  • terinjokes
  • third774
  • jsteinberger
  • jasnell
  • asapzacy
  • pcostanzo
  • gregbrimble
  • geelen
  • rexscaria
  • dcruz_cf
  • xuranwang
  • jculvey
  • sejoker
  • vasturiano
  • cf-ci-write
  • segments-write
  • thibmeu
  • xortive
  • gurjinder
  • cf-ci2
  • lvalenta
  • worenga