Skip to content

Instantly share code, notes, and snippets.

@austinmao
Last active November 26, 2015 03:03
Show Gist options
  • Save austinmao/b2da974571d11e3f24f3 to your computer and use it in GitHub Desktop.
Save austinmao/b2da974571d11e3f24f3 to your computer and use it in GitHub Desktop.
Higher Order Component and Higher Order Reducer example that creates a reusable, redux-connected photo gallery that will store a `selected` state in a unique, nested name + key object in the redux store. Follow this order: reducers/gallery.js -> components/reduxGallery.js -> components/PhotoItems.js -> components/Gallery.js
/**
* createHigherOrderGallery.js
* Creates a HOC that knows how to create redux-connected sub-components.
*/
/** boilerplate */
import React, {Component, PropTypes} from 'react';
import {connect} from 'react-redux';
import {pushState} from 'redux-router';
/** helpers */
import wrapMapDispatchToProps from 'helpers/wrapMapDispatchToProps';
import bindActionData from 'helpers/bindActionData';
/** reducers */
import {get} from 'redux/modules/creative/gallery';
import * as actions from 'redux/modules/creative/gallery';
/** render components */
/**
* wrapped component inherits props from Gallery
* @type {function} - higher order component
*/
export default (config, mapStateToProps, mapDispatchToProps) => WrappedComponent =>
// connect to redux with passed in params
@connect(
(state, props) => ({
...mapStateToProps(state, props), // map passed in state and props
gallery: state.gallery, // get gallery
selected: get(config, 'selected')(state, props), // memoized selector to get selected images
focused: get(config, 'focused')(state, props), // memoized selector to get selected images
location: state.router.location.pathname, // current url
}),
wrapMapDispatchToProps(
mapDispatchToProps, // dispatch action passed in
// bind gallery, key to actions so reducer can store data in proper node
bindActionData({
...actions,
pushState
}, {
name: config.name, // name of nested key at store.gallery[name]. i.e., 'photoItems'
// NOTE: you can make key dynamic by manually binding `key` when actions are invoked
key: config.key // name of nested key at store.gallery[name][key]. i.e., '[LayerId]'
})
)
)
class ReduxGallery extends Component {
static propTypes = {
/** states */
images: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
url: PropTypes.string.isRequired, // img src url
}).isRequired),
selected: PropTypes.objectOf(
PropTypes.shape({ id: PropTypes.bool })
),
location: PropTypes.string.isRequired, // current path
focused: PropTypes.arrayOf(PropTypes.string), // array of ids
/** actions */
pushState: PropTypes.func.isRequired, // route to view
select: PropTypes.func.isRequired, // select photo if not selected
focus: PropTypes.func.isRequired, // focus on a photo
reset: PropTypes.func.isRequired, // reset selected states
}
/** route to selected if selected */
componentWillReceiveProps(next) {
// only redirect if config says to
if (config.redirectOnFocus && next.focused[0]) {
const {location, pushState} = this.props; // eslint-disable-line no-shadow
// get selected id
const id = next.focused[0];
// remove extra '/' in route
const route = location.slice(-1) === '/' ? location + id : location + '/' + id;
// route to /:photoItemId
return pushState(null, route);
}
}
/** remove selected states before unmounting */
componentWillUnmount() {
// only reset if config says to
if (config.resetOnUnmount) {
this.props.reset(); // reset selected states
}
}
render() {
return (
<WrappedComponent
{...config}
{...this.props}
select={::this.props.select}
focus={::this.props.focus}/>
);
}
};
/**
* PhotoItems.js
* loads and renders photoItems from the server and stores them in redux state.
*/
/** boilerplate */
import React, {Component, PropTypes} from 'react';
/** helpers */
import connectApi from 'helpers/connectApi';
import {api} from 'helpers/apis';
/** reducers */
import {getEntitiesByName} from 'redux/modules/data';
/** render components */
import {reduxGallery, Gallery, Err} from 'components';
// async load data
@connectApi(api.photoItems)
@reduxGallery(
{
name: 'photoItems',
key: 'photoItems',
redirectOnFocus: true,
resetOnUnmount: true,
},
(state, props) => ({
isLoading: state.data.isLoading.photoItems,
loadError: state.data.error.photoItems,
photoItems: getEntitiesByName('photoItems')(state, props),
}),
// {} // mapDispatchToProps
)
export default class PhotoItems extends Component {
static propTypes = {
/** states */
photoItems: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})),
isLoading: PropTypes.bool,
loadError: PropTypes.string,
/** actions */
focus: PropTypes.func.isRequired, // focus on tapped image
};
render() {
const {
isLoading,
loadError,
photoItems,
focus,
} = this.props; // eslint-disable-line no-shadow
// const loading = isLoading && <ProgressBar type="circular" mode="indeterminate" />
const loading = isLoading && <div>Loading...</div>;
const error = loadError && <Err msg={loadError} />;
const gallery = photoItems.length &&
<Gallery images={photoItems} handleClick={focus} />;
return (
<div>
{loading || error}
{gallery}
</div>
);
}
}
/**
* bindActionData.js
* binds action creators to keys that are used by Higher Order Reducers to route
* action results to the proper tree/node in a nested reducer.
* NOTE: this is prior art from https://github.com/erikras/redux-form
*/
import mapValues from './mapValues';
/**
* Adds additional properties to the results of the function or map of functions passed
*/
export default function bindActionData(action, data) {
if (typeof action === 'function') {
return (...args) => ({
...action(...args),
...data
});
}
if (typeof action === 'object') {
return mapValues(action, value => bindActionData(value, data));
}
return action;
}
/**
* wrapMapDispatchToProps.js
* takes the same mapDispatchToProps object from @connect and performs
* bindActionCreators on passed in dispatch actions.
* NOTE: this is prior art from https://github.com/erikras/redux-form
*/
import {bindActionCreators} from 'redux';
const wrapMapDispatchToProps = (mapDispatchToProps, actionCreators) => {
if (mapDispatchToProps) {
if (typeof mapDispatchToProps === 'function') {
if (mapDispatchToProps.length > 1) {
return (dispatch, ownProps) => ({
...mapDispatchToProps(dispatch, ownProps),
...bindActionCreators(actionCreators, dispatch),
dispatch
});
}
return dispatch => ({
...mapDispatchToProps(dispatch),
...bindActionCreators(actionCreators, dispatch),
dispatch
});
}
return dispatch => ({
...bindActionCreators(mapDispatchToProps, dispatch),
...bindActionCreators(actionCreators, dispatch),
dispatch
});
}
return dispatch => ({
...bindActionCreators(actionCreators, dispatch),
dispatch
});
};
export default wrapMapDispatchToProps;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment