Skip to content

Instantly share code, notes, and snippets.

@jptissot
Created January 24, 2017 13:25
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 jptissot/4fc8a75402dccdbffafd966e3785d78b to your computer and use it in GitHub Desktop.
Save jptissot/4fc8a75402dccdbffafd966e3785d78b to your computer and use it in GitHub Desktop.
Api get method handler using redux, redux-sagas, reselect.
import { PropTypes } from 'react';
import { fromJS } from 'immutable';
import { call, put, takeLatest } from 'redux-saga/effects';
import { createSelector, createStructuredSelector } from 'reselect';
import api from './request';
// PropTypes declaration to use with makeCompleteSelector
export const propTypes = {
data: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]).isRequired,
loading: PropTypes.bool.isRequired,
error: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]).isRequired,
};
/**
* Generic function that hides all the boilerplate required to use redux and redux-sagas for get api calls
*
* @export
* @param {string} name of the data under the redux mount point. Also used for action names
* @param {string} mountPoint on the redux state tree
* @param {string|function} url of the api to use. Can also be a function that formats a url. ex: (action) => `/surveys/${action.surveyId}/questionnaires/`
* @returns an object with the mountPoint, reducer, saga, actions, selectors
*/
export default function generateApiGet(name, mountPoint, url) {
// The action string constants
const actionStrings = {
loadAction: `app/api/LOAD_${name.toUpperCase()}`,
successAction: `app/api/LOAD_${name.toUpperCase()}_SUCCESS`,
errorAction: `app/api/LOAD_${name.toUpperCase()}_ERROR`,
};
// Action creator function that create the redux actions. To be used by dispatch
const actions = {
load(params) {
return {
...params,
type: actionStrings.loadAction,
};
},
loaded(data) {
return {
data,
type: actionStrings.successAction,
};
},
error(error) {
return {
type: actionStrings.errorAction,
error,
};
},
};
// The initial state of the reducer
let initialState = {
loading: false,
error: false,
};
// dynamic name awesomeness
initialState[name] = false;
initialState = fromJS(initialState);
// The actual redux reducer that will set the data to the state
const reducer = (state = initialState, action) => {
switch (action.type) {
case actionStrings.loadAction:
return state
.set('loading', true)
.set('error', false);
// .set(name, false);
case actionStrings.successAction:
return state
.set(name, action.data)
.set('loading', false)
.set('error', false);
case actionStrings.errorAction:
return state
.set('error', action.error)
.set('loading', false);
// .set(name, false);
default:
return state;
}
};
// function that returns an array containing the watchFetch saga.
const makeSaga = () => {
function* fetchApi(action) {
try {
const data = yield call(api.get, (typeof url === 'function') ? url(action) : url);
if (!data.err) {
yield put(actions.loaded(data.data));
} else {
yield put(actions.error(data.err));
}
} catch (err) {
yield put(actions.error(err));
}
}
function* watchFetch() {
yield takeLatest(actionStrings.loadAction, fetchApi);
}
return [watchFetch];
};
// Function that creates the selectors for this api call
const makeSelectors = () => {
// root selector for this data.
const selectDomain = (state) => state.get(mountPoint);
// selector that returns the root of the data
const makeSelectData = () => createSelector(
selectDomain,
(domainState) => domainState.get(name)
);
// error selector
const makeSelectError = () => createSelector(
selectDomain,
(domainState) => domainState.get('error')
);
// loading selector
const makeSelectLoading = () => createSelector(
selectDomain,
(domainState) => domainState.get('loading')
);
// export the selectors
return {
makeCompleteSelector: (extra) => createStructuredSelector({
data: makeSelectData(),
error: makeSelectError(),
loading: makeSelectLoading(),
...extra,
}),
makeSelectData,
makeSelectError,
makeSelectLoading,
};
};
return {
mountPoint,
reducer,
saga: makeSaga(),
actions,
selectors: makeSelectors(),
propTypes,
};
}
import { getAsyncInjectors } from 'utils/asyncInjectors';
import apiGetHelper from './apiGetHelper';
// Helper method to register the reducer and saga to the store
const register = ({ mountPoint, reducer, saga }, { injectReducer, injectSagas }) => {
injectReducer(mountPoint, reducer);
injectSagas(saga);
};
const getApiCalls = {
getUser: apiGetHelper('user', 'data.user', 'user'),
};
// Called from system/app to register the sagas and reducer in the store on app start
export function registerApi(store) {
const asyncInjectors = getAsyncInjectors(store);
// iterate through all api calls and register them to the store
Object.keys(getApiCalls).map((e) => register(getApiCalls[e], asyncInjectors));
}
// Allow other places in the app to use the exported methods
export default getApiCalls;
//taken from react-boilerplate and modified
import 'whatwg-fetch';
import config from 'system/config';
/**
* Parses the JSON returned by a network request
*
* @param {object} response A response from a network request
*
* @return {object} The parsed JSON from the request
*/
function parseJSON(response) {
return response.json();
}
/**
* Checks if a network request came back fine, and throws an error if not
*
* @param {object} response A response from a network request
*
* @return {object|undefined} Returns either the response, or throws an error
*/
function checkStatus(response) {
if (response.status >= 200 && response.status < 300) {
return response;
}
const error = new Error(response.statusText);
error.response = response;
throw error;
}
/**
* Requests a URL, returning a promise
*
* @param {string} url The URL we want to request
* @param {object} [options] The options we want to pass to "fetch"
*
* @return {object} An object containing either "data" or "err"
*/
function request(url, options) {
let myOptions = options;
if (config.UseCredentials) {
myOptions = { ...options, credentials: 'include' };
}
return fetch(`${config.ApiUrl}/${url}`, myOptions)
.then(checkStatus)
.then(parseJSON)
.then((data) => ({ data }))
.catch((err) => ({ err }));
}
/**
* Performs a GET request
*
* @param {string} url The absolute url of the api, this will be prefixed by the currently configured api endpoint
* @param {object} options The options we want to pass to "fetch"
* @return {object} An object containing either "data" or "err"
*/
function get(url, options) {
return request(url, { ...options, method: 'GET' });
}
/**
* Performs a DELETE request
*
* @param {string} url The absolute url of the api, this will be prefixed by the currently configured api endpoint
* @param {object} options The options we want to pass to "fetch"
* @return {object} An object containing either "data" or "err"
*/
function del(url, options) {
return request(url, { ...options, method: 'DELETE' });
}
/**
* Performs a POST request
*
* @param {string} url The absolute url of the api, this will be prefixed by the currently configured api endpoint
* @param {object} options The options we want to pass to "fetch"
* @return {object} An object containing either "data" or "err"
*/
function post(url, options) {
return request(url, { ...options, method: 'POST' });
}
export default {
get,
post,
request,
del,
};
@jptissot
Copy link
Author

big props to react-boilerplate for the base https://github.com/mxstbr/react-boilerplate

@jptissot
Copy link
Author

jptissot commented Jan 25, 2017

DataLoader component that can be used with the above API abstraction.

Usage: const DataLoadedComponent = makeDataLoader(Component)(api.getUser);

// external desp
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';

import { makeSelectLocale } from 'containers/LanguageProvider/redux';
import { propTypes } from 'api/apiGetHelper';

// Component deps
import LoadingIndicator from 'ui/components/LoadingIndicator';
import ErrorMessage from 'ui/components/ErrorMessage';

/**
 * Higher order component that handles DataFetching.
 *
 * @export
 * @param {React.Component} Component The component that needs rendering when data is available.
 * @param {object} The second parameter is an object containing overridable properties
 * @returns a component that handles DataFetching
 */
export default function makeConnectedDataLoader(Component, { afterDataLoaded = () => {}, LoadingComponent = LoadingIndicator, ErrorComponent = ErrorMessage } = {}) {
  const DataLoader = class extends React.PureComponent {
    static propTypes = {
      fetchData: PropTypes.func.isRequired,
      dispatch: PropTypes.func.isRequired,
      ...propTypes,
    }
    static defaultProps = {
      data: false,
    }

    componentDidMount() {
      // load the component's data.
      this.props.fetchData(this.props);
    }

    componentDidUpdate() {
      if (this.props.data) {
        // Hook that allows callers to inject business logic in this component.
        afterDataLoaded(this.props.data, this.props.dispatch);
      }
    }

    render() {
      const { error, loading, data } = this.props;
      // if we have previous data in the store, show it immediatly
      if (data) {
        // show a loading indicator when we are looking to refresh the data
        if (loading) {
          return <div><LoadingComponent /> <Component {...this.props} /> </div>;
        }
        // if we have data in the state but an error occurred loading new data.
        if (error) {
          return <div><ErrorComponent error={error} /> <Component {...this.props} /> </div>;
        }
        return <Component {...this.props} />;
      }

      // Show an error if there is one
      if (error) {
        return <ErrorComponent error={error} />;
      }
      // Loading indicator by default, even for the initial state of data=false, loading = false, error = false
      return <LoadingComponent />;
    }
  };
  /**
   * Function that connects the DataLoader wrapped component to the redux state and dispatch.
   *
   * @param {object} apiLayer The apiGetHelper object that needs to be loaded.
   * @param {function} [extraMapStateToProps object that is spread under the MapStateToProps object
   * @param {function} [extraMapDispatchToProps=(dispatch, ownProps)=>{}] Expects the function to return an object that is spread under the MapDispatchToProps object
   */
  return function connectDataLoaderToRedux(apiLayer, { mapStateToProps = () => {}, mapDispatchToProps = () => {} } = {}) {
    // Connect the api result to the component
    return connect(
      // mapStateToProps
      (state) => {
        return {
          ...apiLayer.selectors.makeCompleteSelector({ locale: makeSelectLocale() })(state),
          ...mapStateToProps(state),
        };
      },
      // mapDispatchToProps
      (dispatch, ownProps) => {
        return {
          // This is overrideable if required by specifying the same key to extraMapDispatchToProps
          fetchData: () => dispatch(apiLayer.actions.load(ownProps)),
          ...mapDispatchToProps(dispatch, ownProps),
          dispatch,
        };
      }
    )(DataLoader);
  };
}

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