I'm refactoring my react/redux app to use redux-observable instead of redux-thunk. Using thunk, I have an api middleware set up to listen for any actions with a CALL_API key and do some manipulation of the data, prepare headers, prepare full url, perform an api call using axios, and also do some additional action dispatches related to an api call.
Importantly, the api middleware dispatches a REQUEST_START action which gives the request an id and sets its status to pending in the network part of my state. When the promise from axios resolves or rejects, the middleware dispatches a REQUEST_END action, updating the state so that the current request is set to resolved or rejected. Then the response is returned to the calling action creator that initially dispatched the CALL_API action.
I have not been able to figure out how to do this with redux-observable. The part about the api middleware described above that I want to replicate is the REQUEST_START and REQUEST_END action dispatches. It's very convenient to have a centralized place where all api call related stuff is handled. I know I can effectively dispatch the REQUEST_START and REQUEST_END actions in each of my epics that does an api call, but I don't want to have to repeat the same code in many places.
I managed to partially solve this by creating an apiCallEpic which listens for actions with type CALL_API and does the above setup for api calls. However, an issue (or rather, something I don't like) is that the epic that initiates the api call (e.g. getCurrentUserEpic) essentially gives up control to apiCallEpic.
So, for example, when the api call succeeds and has a response, I may want to format that response data in some way before dispatching an action to be handled by my reducer. That is, getCurrentUserEpic should do some formatting of data returned from api call before sending to reducer. I was able to achieve something close to this by passing a payloadHandler callback function defined in getCurrentUserEpic that the apiCallEpic can call if/when it gets a successful response. However, I don't like this callback architecture and it seems like there's got to be a better way.
Here is some code that demonstrates my use of api middleware using thunk.
import axios from 'axios';
// actionCreators.js
// action types
const CALL_API = "CALL_API";
const FETCH_CURRENT_USER = "FETCH_CURRENT_USER";
const RECEIVE_CURRENT_USER = "RECEIVE_CURRENT_USER";
// action creators for request start and end
export const reqStart = (params = {}) => (dispatch) => {
const reduxAction = {
type: REQ_START,
status: 'pending',
statusCode: null,
requestId: params.requestId,
}
dispatch(reduxAction);
}
export const reqEnd = (params = {}) => (dispatch) => {
const {
requestId,
response = null,
error = null,
} = params;
let reduxAction = {}
if (response) {
reduxAction = {
type: REQ_END,
status: 'success',
statusCode: response.status,
requestId,
}
}
else if (error) {
if (error.response) {
reduxAction = {
type: REQ_END,
status: 'failed',
statusCode: error.response.status,
requestId,
}
}
else {
reduxAction = {
type: REQ_END,
status: 'failed',
statusCode: 500,
requestId,
}
}
}
dispatch(reduxAction);
}
// some api call to fetch data
export const fetchCurrentUser = (params = {}) => (dispatch) => {
const config = {
url: '/current_user',
method: 'get',
}
const apiCall = {
[CALL_API]: {
config,
requestId: FETCH_CURRENT_USER,
}
}
return dispatch(apiCall)
.then(response => {
dispatch({
type: RECEIVE_CURRENT_USER,
payload: {response},
})
return Promise.resolve({response});
})
.catch(error => {
return Promise.reject({error});
})
}
// apiMiddleware.js
// api endpoint
const API_ENTRY = "https://my-api.com";
// utility functions for request preparation
export const makeFullUrl = (params) => {
// ...prepend endpoint url with API_ENTRY constant
return fullUrl
}
export const makeHeaders = (params) => {
// ...add auth token to headers, etc.
return headers;
}
export default store => next => action => {
const call = action[CALL_API];
if (call === undefined) {
return next(action);
}
const requestId = call.requestId;
store.dispatch(reqStart({requestId}));
const config = {
...call.config,
url: makeFullUrl(call.config),
headers: makeHeaders(call.config);
}
return axios(config)
.then(response => {
store.dispatch(reqEnd({
response,
requestId,
}))
return Promise.resolve(response);
})
.catch(error => {
store.dispatch(reqEnd({
error,
requestId,
}))
return Promise.reject(error);
})
}
// reducers.js
// Not included, but you can imagine reducers handle the
// above defined action types and update the state
// accordingly. Most usefully, components can always
// subscribe to specific api calls and check the request
// status. Showing loading indicators is one
// use case.
Here's the code I've implemented to accomplish a similar thing with redux-observable.
export const fetchCurrentUserEpic = (action$, state$) => {
const requestType = FETCH_CURRENT_USER;
const successType = RECEIVE_CURRENT_USER;
const requestConfig = {
url: "/current_user",
method: "get",
}
const payload = {requestConfig, requestType, successType};
const payloadNormalizer = ({response}) => {
return {currentUser: response.data.data};
}
return action$.ofType(FETCH_CURRENT_USER).pipe(
switchMap((action) => of({
type: CALL_API,
payload: {...payload, requestId: action.requestId, shouldFail: action.shouldFail, payloadNormalizer},
})),
)
}
export const apiEpic = (action$, state$) => {
return action$.ofType(CALL_API).pipe(
mergeMap((action) => (
concat(
of({type: REQ_START, payload: {requestId: action.payload.requestId, requestType: action.payload.requestType}}),
from(callApi(action.payload.requestConfig, action.payload.shouldFail)).pipe(
map(response => {
return {
type: action.payload.successType,
payload: action.payload.payloadNormalizer({response})
}
}),
map(() => {
return {
type: REQ_END,
payload: {status: 'success', requestId: action.payload.requestId, requestType: action.payload.requestType},
}
})
)
)
).pipe(
catchError(error => {
console.log('error', error);
return of({type: REQ_END, payload: {status: 'failed', requestId: action.payload.requestId, requestType: action.payload.requestType}, error});
})
)
)
)
}
Any comments or suggestions are appreciated!