redux-api-middleware
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)
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!
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 :)
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!
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.
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
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?
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!
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
.
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 Hello, I do not know how you use this module, you give an example?. Thank you
@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.
:)
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
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 thank you, Can you help solve this, please。 #91
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。
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 This means that it's also not possible to listen for a promise?
@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