Skip to content

Instantly share code, notes, and snippets.

@thomasboyt
Last active November 30, 2016 19:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thomasboyt/ca8edefb0ef07b8ea9077f50d5464575 to your computer and use it in GitHub Desktop.
Save thomasboyt/ca8edefb0ef07b8ea9077f50d5464575 to your computer and use it in GitHub Desktop.

redux "request" idea

(this is adapted from the redux-happy-async project I made earlier this year, but I think simplified in an easier-to-understand way)

Why?

Managing async action state in Redux is a very, very common topic of discussion. The classical way to do it is to create three action types (for start, error, and success), and then create boilerplate to set loading/error states for each action inside a reducer. This boilerplate adds up fast if you're creating an app with lots of async actions!

This library abstracts away this boilerplate with a ✨ magic ✨ requests reducer that will handle storing and retrieving this state across your application.

Usage

Using Request State

A "request" is simply a tiny little state machine that lives in a requests reducer. It's keyed off of a name (usually an action type) and, optionally, a unique key to help track multiple requests of the same type.

By keeping a request in your Redux state, you can easily trigger a request (through an action creator) in a component and react to the loading/error states of that request in a different component. It is also easy to prevent multiple, identical requests from being in-flight at once.

The request holds the following state:

  • status: This is either REQUEST_IDLE, REQUEST_PENDING REQUEST_SUCCESS, or REQUEST_ERROR.
  • error: This is an error value supplied by the action creator when the error state is entered.
  • key: This is the unique key of the request, if supplied.

Within a component, you can fetch a request from the state:

import {getRequest} from 'LIB_NAME';

function mapStateToProps(state, props) {
  return {
    // gets the request with the unique key actionTypes.getUser -> props.userId
    request: getRequest(state, [actionTypes.getUser, props.userId]);
  };
}

Creating Requests

Requests are created in action creators:

import {requestStart, requestError, requestSuccess} from 'LIB_NAME';

export function fetchUser(userId) {
  return async (dispatch) => {
    dispatch(requestStart([actionTypes.fetchUser, userId]));

    const resp = await window.fetch(/* ... */);

    if (resp.status !== 200) {
      const err = await resp.json();

      dispatch(requestError([actionTypes.fetchUser, userId], error));
      return;
    }

    dispatch(requestSuccess([actionTypes.fetchUser, userId]));

    const data = await resp.json();

    dispatch({
      type: actionTypes.fetchUser,
      user: data,
    });
  };
}

If you attempt to start an already-started request, an error will be thrown.

Resetting Requests

To reset a request - for example, to ensure that when you exit and return to a page, you do not see state from a past request - simply use:

import {requestReset} from 'LIB_NAME';
dispatch(requestReset([actionTypes.getUser, userId]));

TODO: can an in-flight request be reset?

Full Example

// components/UserDisplay.jsx

import React from 'react';
import {connect} from 'react-redux';

import {getRequest, REQUEST_ERROR, REQUEST_SUCCESS} from 'LIB_NAME';

import fetchUser from '../actions/fetchUser';
import * as actionTypes from '../actionTypes';

class DisplayUser extends React.Component {
  componentWillMount() {
    const {id, dispatch} = this.props;
    dispatch(fetchUser(id));
  }

  render() {
    const {request, user} = this.props;

    if (request.status === REQUEST_SUCCESS) {
      return (
        <span>Username: {user}</span>;
      );
    } else if (request.status === REQUEST_ERROR) {
      return (
        <span>Error fetching user: {request.error}</span>
      );
    }

    return <span>Loading user...</span>;
  }
}

function mapStateToProps(state, props) {
  const {id} = props;

  return {
    request: getRequest(state, [actionTypes.fetchUser, id]);
    user: state.users[id],
  };
}

export default connect(mapStateToProps)(UserDisplay);
// actions/fetchUser.js

import {requestStart, requestError, requestSuccess} from 'LIB_NAME';

export function fetchUser(userId) {
  return async (dispatch) => {
    dispatch(requestStart([actionTypes.fetchUser, userId]));

    const resp = await window.fetch(/* ... */);

    if (resp.status !== 200) {
      const err = await resp.json();

      dispatch(requestError([actionTypes.fetchUser, userId], error));
      return;
    }

    dispatch(requestSuccess([actionTypes.fetchUser, userId]));

    const data = await resp.json();

    dispatch({
      type: actionTypes.fetchUser,
      user: data,
    });
  };
}
// reducers/users.js

export default function userReducer(state = {}, action) {
  if (action.type === actionTypes.fetchUser) {
    const {id} = action.user;

    return {
      [id]: action.user,
      ...state,
    };
  }

  return state;
}

FAQ

Can my own reducers handle the request* action creators?

They potentially could, but a better way to handle custom state updating would be to simply dispatch another action alongside your request* action.

How can I have multiple instances of a request if there is no obvious unique key to use?

You can create a counter in your action creator:

let todoRequestId = 0;
export function createTodo(text) {
  return async (dispatch) => {
    dispatch(requestStart([actionTypes.createTodo, todoRequestId]));
    // ...
  };
}

You'll likely want to then get a list of pending requests to be able to reference/display these requests (see below).

How can I manage pending requests of a given type?

You can filter for requests from the async reducer:

const createTodoRequests = store.getState().requests[actionTypes.createTodo];
const pending = createTodoRequests.map((request) => request.status === REQUEST_PENDING);
import _ from 'lodash';
import updeep from 'updeep';
function createReducer(initialState, handlers) {
return (state = initialState, action) => {
if (handlers.hasOwnProperty(action.type)) {
return handlers[action.type](state, action);
} else {
return state;
}
};
}
// Action constants (intended for internal use only)
const REQUEST_ACTION_START = '@request/start';
const REQUEST_ACTION_SUCCESS = '@request/success';
const REQUEST_ACTION_ERROR = '@request/error';
const REQUEST_ACTION_RESET = '@request/reset';
// Request status constants
export const REQUEST_IDLE = 'idle';
export const REQUEST_PENDING = 'pending';
export const REQUEST_SUCCESS = 'success';
export const REQUEST_ERROR = 'error';
/**
* Request shape:
* - status: one of REQUEST_IDLE, REQUEST_PENDING, REQUEST_SUCCESS, REQUEST_ERROR
* - error: an error value
* - key: the unique key of the request
*/
const initialState = {};
export const reducer = createReducer(initialState, {
[REQUEST_ACTION_START]: (state, {key}) => {
const req = getRequest(state, key);
if (req.status === REQUEST_PENDING) {
// TODO: Have a better bail-out path for this?
// Could be moved to the action creator and dispatch e.g. REQUEST_ACTION_ATTEMPTED_RESTART_PENDING
// So the frontend could handle by ignoring/canceling/etc?
throw new Error(`Attempted to restart pending action ${key}`);
}
return updateRequest(state, key, {
status: REQUEST_PENDING,
});
},
[REQUEST_ACTION_SUCCESS]: (state, {key}) => {
return updateRequest(state, key, {
status: REQUEST_SUCCESS,
});
},
[REQUEST_ACTION_ERROR]: (state, {key, error}) => {
return updateRequest(state, key, {
status: REQUEST_ERROR,
error,
});
},
[REQUEST_ACTION_RESET]: (state, {key}) => {
return updateRequest(state, key, {
status: REQUEST_IDLE,
});
},
});
function updateRequest(state, key, opts) {
return updeep.updateIn(key, updeep.constant(createRequest(key, opts)), state);
}
function createRequest(key, opts) {
return {
key,
...opts,
};
}
// Selectors
export function getRequest(state, key) {
ensureArray(key);
const req = _.get(state, key);
if (!req) {
return createRequest(key, {status: REQUEST_IDLE});
}
return req;
}
// Actions
export function requestStart(key) {
ensureArray(key);
return {
type: REQUEST_ACTION_START,
key,
};
}
export function requestError(key, error) {
ensureArray(key);
return {
type: REQUEST_ACTION_ERROR,
key,
error,
};
}
export function requestSuccess(key) {
ensureArray(key);
return {
type: REQUEST_ACTION_SUCCESS,
key,
};
}
export function requestReset(key) {
ensureArray(key);
return {
type: REQUEST_ACTION_RESET,
key,
};
}
function ensureArray(key) {
if (!Array.isArray(key)) {
throw new Error(`Invalid request key ${key}: should be an array`)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment