Skip to content

Instantly share code, notes, and snippets.

@parkerault
Last active March 14, 2018 20:37
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save parkerault/9dc7e825cc9a62b5efa8a4c1cadcc558 to your computer and use it in GitHub Desktop.
Save parkerault/9dc7e825cc9a62b5efa8a4c1cadcc558 to your computer and use it in GitHub Desktop.
Redux Modules w/ API Middleware

This is extracted from a project that's been in development for about two months; I tried to include just enough code to give you an idea how I restrict async operations to custom middleware and keep all actions synchronous and declarative. The original inspiration was the real-world example from redux. This isn't a terribly complicated example, but an action that kicks off a multi-step async function would have basically the same structure: the action provides whatever configuration is necessary to perform that task, and the middleware orchestrates the async control flow and dispatches intermediate actions as required. I know some people will say that moving async operations to a custom middleware instead of an action creator is just rearranging the deck chairs on the titanic, but in my experience it makes a huge difference as the project grows in complexity and prevents the action creators and reducers from devolving into a pile of spaghetti. In practice I am finding that most CRUD projects never require more than the API middleware, unless you are using a third party API that has some seriously wonky usability issues.

Note: asyncActionTypes returns an object with the structure:

{
  DEFAULT: actionPrefix,
  PENDING: `${actionPrefix}_PENDING`,
  SUCCESS: `${actionPrefix}_SUCCESS`,
  FAILURE: `${actionPrefix}_FAILURE`
}

Action creator and reducer from /src/modules/session.js (I prefer to organize action creators and reducers into modules that mirror API resources, but the file structure is inconsequential):

import { asyncActionTypes } from '../utils';
import { CALL_API, Session } from '../conf/api';

const CREATE = asyncActionTypes('redacted/session/CREATE');

export const actions = { CREATE }

export function createSession({email, password, version="v1.0"}) {
  return {
    type: CREATE.DEFAULT,
    payload: { email, password },
    meta: {
      [CALL_API]: {
        url: Session[version].endpoint,
        method: 'POST',
        actionTypes: CREATE
      }
    }
  }
}

// The name clash between the action `meta` property and the store module
// `meta` property could be confusing and should probably be changed, but the
// intention is to have a single property that holds all ephemeral UI state
// so the localStorage writer can strip it out before serialization. This
// reducer pattern is a little rough and we're working on something a little
// more robust using immutable and some helper functions.
export default function sessionReducer(state={}, action) {
  switch(action.type) {
    case CREATE.PENDING:
      return {
        ...state,
        meta: { ...state.meta, ...action.meta }
      }
    case CREATE.FAILURE:
      // A separate reducer collects all `*_FAILURE` actions payloads and a
      // "notifications" component renders the user-facing error messages.
      return {
        ...state,
        meta: { ...state.meta, ...action.meta, error: action.payload }
      }
    case CREATE.SUCCESS:
      return {
        ...action.payload,
        meta: { ...state.meta, ...action.meta }
      }
    default: return state;
  }
}

The API redux middleware from /src/middleware/api.js (There are quite a few utility functions and imports excluded here, but the middleware function should give you a good idea what's going on):

export default (store) => (next) => async (action) => {
  // exit and pass the action down the middleware chain if it's not an API action
  const maybeCallAPI = action.meta && action.meta[CALL_API];
  if (!maybeCallAPI) {
    return next(action);
  }
  let actionTypes = maybeCallAPI.actionTypes;
  // Put everything in a try/catch block so we can dispatch the error as the
  // payload of a FAILURE action.
  try {
    const { session } = store.getState();
    // validate everything needed for the api call and throw an APIRequestError
    // if anything is missing or malformed. An Authorization header will be
    // created with the session token if the user is signed in.
    const callAPI = validateCallAPI(maybeCallAPI, session);
    if (!callAPI) throw new APIRequestError({action});
    const { headers, url, method } = callAPI;
    actionTypes = callAPI.actionTypes;
    // dispatch PENDING action for network status updates; this goes back to
    // the head of the middleware chain (we don't see it again because it
    // doesn't have a meta.CALL_API property). This is useful if you have
    // other middleware that listens for network status updates.
    store.dispatch({
      type: actionTypes.PENDING,
      payload: action.payload,
      meta: { ...action.meta, pending: true }
    });
    const body = JSON.stringify(action.payload);
    const response = await fetch(url, { method, headers, body });
    // Parse the response or throw an error if we get anything unexpected;
    // This wouldn't be suitable for a generalized API middleware but we
    // know what to expect from our internal APIs.
    const contentType = response.headers.get('Content-Type');
    let data;
    if (contentType.match(/text\/plain/)) {
      data = await response.text();
    } else if (contentType.match(/application\/json/)) {
      data = await response.json();
    } else {
      throw new UnknownContentTypeError({url, contentType, response});
    }
    // validate reponse or throw APIResponseError
    if (!response.ok) {
      const {code, problems} = data;
      throw new APIResponseError({code, problems}, response.statusText);
    }
    // Remove CALL_API before dispatching so we don't get stuck in a loop,
    // then exit; `discardCallAPI` merges the original action with the
    // response data and sets `meta.pending` to `false`.
    return store.dispatch(discardCallApi(action, {
      type: actionTypes.SUCCESS,
      payload: data
    }));
  } catch(error) {
    // dispatch a FAILURE action and exit
    return store.dispatch(discardCallApi(action, {
      type: actionTypes.FAILURE,
      payload: error,
      error: true
    }));
  }
}
@adamcee
Copy link

adamcee commented Mar 14, 2018

This is very cool! We have had a somewhat similar approach, but stored request/response success/error states in request-specific reducers (it may have been better to put that data in the actions).

I think you will find of interest how we've used Immutable.js Maps for reducer state and Immutable.js Records as an easy way to create and pass around (guaranteed immutable) action types.

We were using Immutable.js and functional-style JS a lot in general, so it was a good fit.

https://gist.github.com/adamcee/3191762f2af43ec62a4b335b66955cc8

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment