redux-api-middleware icon indicating copy to clipboard operation
redux-api-middleware copied to clipboard

API calls that dispatch multiple actions for keeping API response data and other stuff (like metadata, etc.) in separate parts of the store? (With sample code)

Open anyong opened this issue 8 years ago • 17 comments

I'm working on what is basically a CMS and has about ~30 or so different model classes that are rather inter-related. The easiest way to work with them is definitely with normalizr as suggested. Now, for my redux store object, I'm thinking the best way to set things up will be:

store = {
    // Any loaded entities from the API go here
    entities: {
        // merged directly from from normalizr `entities`
        modelA: {
            1: { // ...
            // ...
        },
        modelB: { // ...
            // ...
    },

    // Keep track of all other normal state for managing UI, pagination, etc...
    modelAListView: {
        page: 1,
        selected: [1, 2, 3, 4, 5] // the ID numbers of the objects currently in view
        // ...
    },
    modelBDetailView: {
        result: 17 // id number of modelB in view
    }
    // etc
}

So, my question is, in order to get this to happen with redux-api-middleware, I need a reducer for the state.entities (the apiReducer), and then my individual reducers as normal for all of the different views and such.

But then, I have to dispatch two separate actions to make sure that (A) the apiReducer receives updates whenever the API gets called, and (B) the appropriate model reducer receives updates when the API call involves that particular model.

I have worked out a solution to do this using redux-thunk, but I would really appreciate any feedback on this approach. So far it's working very nicely, and means my actual API calls are super simple to make from within my redux action creators. I would love to know if there is a better way anyone else has come up with!

So, first, here's my helper utility to make API requests with secondary action effects:

// redux/helpers/makeApiRequest.js

// set a default API root (or headers, credentials, etc.) so we don't need to type these everywhere
const apiRoot = '/api/v1';

export function makeApiRequest (options) {
    const {method = 'GET', path, query, schema, secondaryActionTypes, ...rest} = options;

    const endpoint = url.format({query, pathname: path.join(apiRoot, path)});

    let apiAction;

    // return a function that takes dispatch and add the `redux-thunk` middleware
    return dispatch => {
        if (Array.isArray(secondaryActionTypes) && secondaryActionTypes.length === 3) {

            // These are API hits that require a secondary update in a related reducer
            apiAction = {
                [CALL_API]: {
                    method, endpoint,
                    types: [{
                        type: secondaryActionTypes[0],
                        payload: () => dispatch({type: API_REQUEST}),
                    }, {
                        type: secondaryActionTypes[1],
                        payload: (action, state, res) => onSuccess(dispatch, res, schema),
                                                         // see helper function below
                    }, {
                        type: secondaryActionTypes[2],
                        payload: (action, state, res) => onFailure(dispatch, res),
                                                         // see helper function below
                    }],
                },
            };
        } else {

            // This is a normal `redux-api-middleware` type action for actions
            // that don't require updates specifically to the API entities reducer
            apiAction = {[CALL_API]: {method, endpoint, ...rest}};
        }

        return dispatch(apiAction);
    };
}

function onSuccess (dispatch, res, schema) {
    return getJSON(res)
        .then(json => {
            const data = normalize(json, schema);

            // Dispatch the API Action (will merge with `entities` branch of store)
            dispatch({
                type: API_SUCCESS,
                payload: {
                    entities: data.entities,
                },
            });

            // Payload for the secondary action type, will typically be merged into
            // a related model reducer somewhere else in the store
            return {
                result: data.result,
            };
        });
}

function onFailure (dispatch, res) {
    return getJSON(res)
        .then(json => {
            // Same as the default error action from `redux-api-middleware`
            const payload = new ApiError(res.status, res.statusText, json);

            // Send to the API reducer and return for the secondary reducer
            dispatch({type: API_FAILURE, payload});
            return payload;
        });
}

Now the next one is super simple, to update the entities branch of the store:

// redux/reducers/entities.js

const initialState = {
    modelA: {},
    modelB: {},
    // etc.
};

function reducer (state = initialState, action) {
    switch (action.type) {
        case API_REQUEST:
            // ...
        case API_SUCCESS:
            if (action.payload && action.payload.entities) {
                return Object.assign({}, state, action.payload.entities);
            }
            break;
        case API_FAILURE:
            // ...
        default: return state;
    }
}

export default reducer;

Now calling the API from any action is as easy as:

function getModelA (query) {
    const endpoint = 'model_a';

    return apiRequest({
        endpoint, query,
        schema: Schemas.MODEL_A,
        secondaryActionTypes: [MODEL_A_REQEST, MODEL_A_SUCCESS, MODEL_A_FAILURE],
    });
}

and I will have access to all of the data I need in reducers that handle both API actions and MODEL_A related actions.

Comments/feedback/suggestions? Thanks!

anyong avatar Apr 15 '16 10:04 anyong

Hey this is really great!

I'm just getting my feet wet with this library, but I'm going to apply what you have here to my own project and see if there's anything I would change :)

peterpme avatar Apr 21 '16 14:04 peterpme

Hey @anyong!

The initialState that you define in your reducer, does that play the same role combineReducers would? Is that where you're going with that?

Are you explicitly listing all of your models there as your root reducer or do you handle that somewhere else too?

Thanks!

peterpme avatar Apr 21 '16 15:04 peterpme

The models are just the normalizr schemas that will be passed to normalize(), I just put them all in a file called models.js. The initialState set up with empty objects for each model simply means you won't get a bunch of cannot access property foo of undefined errors before data is loaded, and any map over the entities will just be an empty array.

anyong avatar Apr 21 '16 16:04 anyong

Thanks for the quick reply!

Gotcha. Just so I understand, it looks like you're getting rid of having to use combineReducers because everything is handled within the api reducer, right?

The reason why I'm asking is because I have a series of reducers that I use with combineReducers:

export default combineReducers({
  router,
  form,
  users,
  vendors,

  // etc..
})

The approach you're taking is a bit different, because the API reducer will handle everything on its own and normalize is actually defining the store at a top level, right? Not nested within a reducer?

Does that make sense?

Thanks

peterpme avatar Apr 21 '16 16:04 peterpme

The top-level entities reducer only takes care of the data. Any other information you need about your state should be coming from another reducer, in particular, things like which models (by ID) are currently displayed on the screen, errors, and that sort of thing. I've got something like:

{
  entities: {
    modelA: { 1: { .... }, 2: { ... } }
  },
  modelAListView: {
    active: [1, 2]
  }
}

Make sense?

anyong avatar Apr 21 '16 16:04 anyong

Hey @anyong

Just wanted to let you know this all makes sense. I took a look through the real-world example and could match the ideas.

Thank you for helping me out!

peterpme avatar Apr 22 '16 01:04 peterpme

Hey @anyong ,

After playing around with this for a few days. Here are some pain points:

CRUD.

This technique seems to be working great when you only have to fetch stuff. When you try to do much more than that, it gets a little complicated, especially with normalizr.

peterpme avatar Apr 26 '16 20:04 peterpme

Ok, sorry for the lack of context. Did a ton of refactoring on my end.

For anyone else interested, this can DEFINITELY be cleaned up, but I just needed something that works for now.

Background: Pure CRUD Api:

DELETE: returns no response body, 204 GET ALL: keyed, ie: customers: [{}] UPDATE: no key, ie: {_id: xyz}

import {
  CALL_API,
  getJSON,
  ApiError
} from 'redux-api-middleware'
import {normalize} from 'normalizr'
import {omit, values} from 'lodash'

const HEADERS = {
  'Accept': 'application/json',
  'Content-Type': 'application/json'
}

const CREDENTIALS = 'same-origin'

export const API_REQUEST = 'API_REQUEST'
export const API_SUCCESS = 'API_SUCCESS'
export const API_FAILURE = 'API_FAILURE'

const API_ROOT = '/api'

export default function ({
  method = 'GET',
  body,
  headers = HEADERS,
  credentials = CREDENTIALS,
  url,
  actionTypes,
  schema,
  ...rest
}) {
  const endpoint = `${API_ROOT}/${url}`
  let apiAction

  const config = {}
  config.method = method
  config.endpoint = endpoint
  config.headers = headers
  config.credentials = credentials

  if (body) config.body = body

  return dispatch => {
    if (Array.isArray(actionTypes) && actionTypes.length === 3) {
      apiAction = {
        [CALL_API]: {
          ...config,
          types: [
            {
              type: actionTypes[0],
              payload: () => dispatch({type: API_REQUEST})
            },
            {
              type: actionTypes[1],
              payload: (action, state, res) => onSuccess(dispatch, res, schema, method, url, state)
            },
            {
              type: actionTypes[2],
              payload: (action, state, res) => onFailure(dispatch, res, method, url, state)
            }
          ]
        }
      }
    } else {
      apiAction = {
        [CALL_API]: {
          ...config,
          ...rest
        }
      }
    }

    return dispatch(apiAction)
  }
}

function handleNormalResponse (dispatch, res, schema, json) {
  let data
  if (Object.keys(json)[0] === schema.key) {
    data = normalize(json, {[schema.key]: schema.type})
  } else {
    data = normalize(json, schema.type)
  }

  let ids = {}

  if (typeof data.result === 'string') {
    ids = data.result
  } else {
    Object.keys(data.entities).map(entity => {
      ids[entity] = Object.keys(data.entities[entity]).map(id => id)
    })
  }

  dispatch({
    type: API_SUCCESS,
    payload: {
      entities: data.entities,
      ids
    }
  })

  return {
    ids
  }
}

function handleDeleteResponse (dispatch, schema, url, state) {
  const id = url.split('/').pop()
  const ids = state[schema.key].ids

  const items = state.entities[schema.key]
  const toKeep = ids.filter(key => key !== id)
  const itemsToKeep = omit(items, id)

  dispatch({
    type: API_SUCCESS,
    payload: {
      entities: {
        [schema.key]: itemsToKeep
      },
      ids: toKeep
    }
  })

  return {
    ids: toKeep,
    deleted: id
  }
}

function onSuccess (dispatch, res, schema, method, url, state) {
  return getJSON(res)
  .then(json => {
    switch (method) {
      case 'DELETE':
        return handleDeleteResponse(dispatch, schema, url, state)
      default:
        return handleNormalResponse(dispatch, res, schema, json)
    }
  })
}

function onFailure (dispatch, res, state, method) {
  return getJSON(res)
  .then(json => {
    const payload = new ApiError(res.status, res.statusText, json)

    if (payload.status === 401) {
      window.location = '/'
    }

    dispatch({
      type: API_FAILURE,
      payload
    })

    return payload
  })
}

When you fire something off, you'll two things dispatch (thanks to thunks):

API_SUCCESS WHATEVER_SUCCESS

(and requests/failures go along with it)

This is still going through a bit of a refactor, but:

[API_SUCCESS]: (state, action) => {
    if (action.payload && action.payload.entities) {
    // whether you update, delete or GET, it'll always return the new stuff you want to replace!
   }
}

peterpme avatar Apr 27 '16 02:04 peterpme

@peterpme Hello, I do not know how you use this module, you give an example?. Thank you

janjon avatar Jul 05 '16 03:07 janjon

@janjon

  • create api/xhr.js file, paste this in there
  • within your async action creators, call api
  • create an api reducer that handles API_SUCCESS,FAILURE,REQUEST, etc.

:)

peterpme avatar Jul 05 '16 16:07 peterpme

hi @peterpme How to understand this code?

let data
  if (Object.keys(json)[0] === schema.key) {
    data = normalize(json, {[schema.key]: schema.type})
  } else {
    data = normalize(json, schema.type)
  }

Thank you

janjon avatar Jul 09 '16 13:07 janjon

So this was an edge case I was dealing with. The server would sometimes respond with a keyed payload and sometimes it would return the payload:

customers: {
  name: 'customer 1'
 // ...
}

or

{
name: 'customer 1'
}

Normalizr didn't like that, so I tweaked it a bit.

peterpme avatar Jul 10 '16 13:07 peterpme

@peterpme thank you, Can you help solve this, please。 #91

janjon avatar Jul 11 '16 02:07 janjon

hi @peterpme , Sorry, I go again. I have a question that I want to be like real-world as

function fetchStarred(login, nextPageUrl) {
  return {
    login,
    [CALL_API]: {
      types: [ STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE ],
      endpoint: nextPageUrl,
      schema: Schemas.REPO_ARRAY
    }
  }
}

But we would like to achieve this in this example

function fetchStarred(login, nextPageUrl) {
  return  api({
    actionTypes: [
      { type: STARRED_REQUEST, meta: { login: login } },
      { type: STARRED_SUCCESS, meta: { login: login } },
      { type: STARRED_FAILURE, meta: { login: login } }],
  })
}

mapActionToKey: action => action.meta.login,  //To use it like this

There is no better way。

janjon avatar Jul 25 '16 13:07 janjon

you will have to write your own call-api middleware for that unless this middleware supports it. It's relatively easy and http://redux.js.org/ actually has one for you to use :)

peterpme avatar Jul 25 '16 16:07 peterpme

@peterpme This means that it's also not possible to listen for a promise?

mvhecke avatar Aug 01 '16 08:08 mvhecke

@Gamemaniak whether you write your own custom api middleware or use this one, you can return a promise. I'm not sure what you mean by listening for a promise

peterpme avatar Aug 01 '16 13:08 peterpme