Last active
November 26, 2015 03:03
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* gallery.duck.js | |
* reducers for gallery | |
*/ | |
import {createSelector} from 'reselect'; | |
const SELECT = 'aditive/creative/gallery/SELECT'; | |
const FOCUS = 'aditive/creative/gallery/FOCUS'; | |
const RESET = 'aditive/creative/gallery/RESET'; | |
const initialState = { | |
selected: {}, // default to first layer to focus on | |
focused: [], // history of images focused on | |
}; | |
/** reducer that is passed to the Higher Order Reducer */ | |
function reducer(state = initialState, action = {}) { | |
switch (action.type) { | |
case SELECT: | |
return { | |
...state, | |
selected: { | |
...state.selected, | |
// add id as key if selected. remove if not. | |
[action.id]: state.selected[action.id] ? undefined : true | |
} | |
}; | |
case RESET: | |
return { | |
...state, | |
...initialState, | |
}; | |
case FOCUS: | |
return { | |
...state, | |
focused: [ | |
...state.focused, | |
action.id | |
] | |
}; | |
default: | |
return state; | |
} | |
} | |
/** | |
* higher order reducer that passes name and key in to nest states | |
* @return {function} - decorated reducer | |
*/ | |
export default function galleryReducer(state = {}, action = {}) { | |
const {name, key, ...rest} = action; // eslint-disable-line no-redeclare | |
if (!name) { | |
return state; | |
} | |
if (key) { // unique identifier for each instance of gallery of the same name | |
return { | |
...state, | |
[name]: { // tree | |
...state[name], | |
[key]: reducer((state[name] || {})[key], rest) // call reducer at key | |
} | |
}; | |
} | |
return { // do not nest under name if no unique key is passed | |
...state, | |
[name]: reducer(state[name], rest) | |
}; | |
} | |
/** | |
* action creators | |
*/ | |
/** select a photo */ | |
export function select(id) { | |
return { | |
type: SELECT, | |
id, | |
}; | |
} | |
export function focus(id) { | |
return { | |
type: FOCUS, | |
id, | |
}; | |
} | |
/** reset selections */ | |
export function reset() { | |
return { | |
type: RESET, | |
}; | |
} | |
/** | |
* selectors | |
*/ | |
/** | |
* memoized selector to get the selected state of the currenty gallery.key | |
* @param {object} config | |
* @param {object} config.name - name of tree | |
* @param {object} config.key - name of node | |
* @return {object} - selected object of { [id]: [bool] } | |
*/ | |
export function get({name, key}, param) { | |
// get gallery at name.key | |
const gallerySelector = state => { | |
// return node results if node exists | |
if (name && key && state.gallery[name]) { | |
const _gallery = state.gallery[name]; | |
if (_gallery[key]) { | |
return _gallery[key]; // get gallery | |
} | |
return _gallery; // return state.gallery[name] if no key | |
} | |
// otherwise return initial state | |
return {...initialState}; // return initial state | |
}; | |
/** @return array of selected images or empty array */ | |
return createSelector( | |
gallerySelector, | |
gallery => gallery[param] // this will be initialState if name or key is empty | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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}/> | |
); | |
} | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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> | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Gallery.js | |
* dumb component that renders a gallery using material-ui | |
*/ | |
/** boilerplate */ | |
import React, {Component, PropTypes} from 'react'; | |
/** helpers */ | |
import Radium from 'radium'; | |
/** render components */ | |
import {GridList, GridTile} from 'material-ui-updated'; | |
@Radium | |
export default class Gallery extends Component { | |
static propTypes = { | |
/** states */ | |
images: PropTypes.array.isRequired, | |
selected: PropTypes.object, | |
/** actions */ | |
handleClick: PropTypes.func, | |
handleMouseOver: PropTypes.func, | |
}; | |
state = { | |
gridList: { | |
cols: 2, | |
// padding: 1, | |
// cellHeight: 200, | |
style: { | |
width: 400, | |
height: 400, | |
// overflowY: 'auto', | |
}, | |
}, | |
}; | |
styles = { | |
img: { | |
} | |
}; | |
render() { | |
const {images, selected, handleClick, handleMouseOver} = this.props; | |
const {styles} = this; | |
// render gallery with image and selected state. handle onClick and onMouseOver | |
// events with passed in props | |
const gallery = images.length > 0 && images.map(image => | |
<GridTile key={image.id}> | |
<img ref={'img_' + image.id} | |
src={image.url} | |
onClick={handleClick && handleClick.bind(this, image.id)} | |
onMouseOver={handleMouseOver && handleMouseOver.bind(this, image.id)} | |
styles={[styles.img]} /> | |
{/* TODO: make this prettier */} | |
{ selected && selected[image.id] && <i className="fa fa-check" /> } | |
</GridTile> | |
); | |
return ( | |
<GridList {...this.state.gridList}> | |
{gallery} | |
</GridList> | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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