Skip to content

Instantly share code, notes, and snippets.

@cpsubrian
Last active March 27, 2023 13:34
Show Gist options
  • Save cpsubrian/79e97b6116ab68bd189eb4917203242c to your computer and use it in GitHub Desktop.
Save cpsubrian/79e97b6116ab68bd189eb4917203242c to your computer and use it in GitHub Desktop.
React decorators for redux/react-router/immutable 'smart' components.

This is my typical decorator stack for a 'smart component' used as the component for react-router route. Note some code is missing here but this should give you the idea.

Example usage:

StateDetailsScene.js

import React from 'react'
import _ from 'lodash'
import route from 'core/decorators/route'
import {listActivity} from 'esa/actions/activity'
import {stateSelector} from 'esa/selectors/states'
import {statsSelector} from 'esa/selectors/stats'
import {stateLegislatorsSelector} from 'esa/selectors/legislators'
import {activityListSelector} from 'esa/selectors/activity'
import StateLayout from 'esa/components/layouts/state/StateLayout'
import SectionedView from 'esa/components/sectioned-view/SectionedView'
import Stats from 'esa/components/stats/Stats'
import Activity from 'esa/components/activity/Activity'
import Legislators from 'esa/components/legislators/Legislators'
import Loading from 'core/components/loading/Loading'
import {detailsLoading} from 'theme/styles/scenes'

@route({
  loader: ({dispatch, params}) => [
    dispatch(listActivity({query: {tags: `states:${params.stateId}`}}))
  ],
  selectors: [
    stateSelector,
    statsSelector,
    stateLegislatorsSelector,
    activityListSelector
  ],
  toJS: true
})
class StateDetailsScene extends React.Component {

  static propTypes = {
    params: React.PropTypes.object,
    state: React.PropTypes.object,
    stats: React.PropTypes.object,
    activity: React.PropTypes.object,
    legislators: React.PropTypes.object
  }

  static defaultProps = {
    activity: {}
  }

  getSections () {
    let sections = []

    if (this.props.stats) {
      sections.push({
        id: 'stats',
        title: 'Stats',
        showHeader: false,
        render: () => <Stats stats={this.props.stats}/>
      })
    }

    if (this.props.legislators) {
      sections.push({
        id: 'legislators',
        title: 'Legislators',
        showHeader: true,
        render: () => <Legislators legislators={_.values(this.props.legislators)}/>
      })
    }

    if (this.props.activity.list && this.props.activity.list.length) {
      sections.push({
        id: 'activity',
        title: 'Recent Activity',
        showHeader: true,
        render: () => <Activity activity={this.props.activity.list}/>
      })
    }

    return sections
  }

  render () {
    let sections = this.getSections()
    return this.props.state ? (
      <StateLayout state={this.props.state}>
        {sections.length ? (
          <SectionedView sections={sections}/>
        ) : (
          <Loading spinnerSize='large' fontSize={16} style={detailsLoading}/>
        )}
      </StateLayout>
    ) : null
  }
}

export default StateDetailsScene

selectors/states.js

import {createSelector} from 'reselect'

// Select a state
export const stateSelector = createSelector(
  (state) => state.states,
  (state, props) => {
    if (props.params.stateId) {
      return props.params.stateId
    }
    if (props.params.districtId) {
      return props.params.districtId.substr(0, 2)
    }
    return null
  },
  (states, stateId) => {
    return {
      state: states.getIn(['states', stateId])
    }
  }
)
export default function getDisplayName (Component) {
return Component.displayName || Component.name || 'Component'
}
import React from 'react'
import _ from 'lodash'
import getDisplayName from 'core/lib/getDisplayName'
/**
* Loaders let us fetch data in the front-end and on the server. Use this
* decorator on router components only. It MUST be the outer-most decorator
* in order for the server-side logic to find the static method this attaches.
*
* On the front-end, the loader will be run on componentDidMount.
* On the server, the loader will run before rendering.
*
* Loaders should return a promise or an array of promises.
*/
export default function loader (loader) {
return function loaderDecorator (Component) {
class LoaderComponent extends React.Component {
static displayName = `Loader(${getDisplayName(Component)})`
static WrappedComponent = Component
static loader = loader
static contextTypes = {
store: React.PropTypes.object.isRequired,
codemap: React.PropTypes.object.isRequired
}
static propTypes = {
location: React.PropTypes.object.isRequired,
params: React.PropTypes.object.isRequired
}
componentWillMount () {
if (process.env.BROWSER) {
this.runLoader()
}
}
componentDidUpdate (prevProps) {
if (!_.isEqual(prevProps.location, this.props.location)) {
this.runLoader(true)
}
}
runLoader (isUpdate = false) {
let props = this.props
let {store, codemap} = this.context
let {params, location} = this.props
let _serverRendered = store.getState().app.get('_serverRendered')
let _rendered = store.getState().app.get('_rendered')
// Only run loaders if the app has been rendered (we're not
// bootstrapping to the server-side-rendered DOM).
if (!params._loaded && (!_serverRendered || _rendered)) {
loader({
store,
dispatch: store.dispatch,
get: codemap.get,
params,
location,
props,
isUpdate
})
}
}
render () {
return (
<Component {...this.props} />
)
}
}
return LoaderComponent
}
}
import React from 'react'
import {connect} from 'react-redux'
import codemap from 'core/decorators/codemap'
import {loaderSelector} from 'core/selectors/loader'
import getDisplayName from 'core/lib/getDisplayName'
/**
* Stop updates if loaders are running.
*/
export default function loaderFreeze (Component) {
class LoaderFreezeComponent extends React.Component {
static displayName = `LoaderFreeze(${getDisplayName(Component)})`
static WrappedComponent = Component
static propTypes = {
__loader: React.PropTypes.object,
__enabled: React.PropTypes.bool
}
shouldComponentUpdate () {
return !this.props.__enabled || !this.props.__loader._loading
}
render () {
let {__loader, __enabled, ...rest} = this.props
return <Component {...rest}/>
}
}
return (
connect(loaderSelector)(
codemap({__enabled: 'enableRouteLoaders'})(
LoaderFreezeComponent
)
)
)
}
/**
* Combines @loaders, @connect, and @loaderFreeze into a convenience decorator
* to wrap a route component in.
*/
import _ from 'lodash'
import {compose} from 'redux'
import {connect} from 'react-redux'
import toJS from 'core/decorators/toJS'
import loader from 'core/decorators/loader'
import loaderFreeze from 'core/decorators/loaderFreeze'
import selectAll from 'core/lib/selectAll'
export default function route (options = {}) {
return function routeDecorator (Component) {
let stack = _.compact([
options.loader ? loader(options.loader) : null,
options.selectors ? connect(selectAll(options.selectors, true)) : null,
options.actions ? connect(null, options.actions) : null,
options.loader ? loaderFreeze : null,
options.toJS ? toJS(options.toJS) : null
])
return compose(...stack)(Component)
}
}
import _ from 'lodash'
export default function selectAll (selectors, skipWhileLoading) {
return function mapStateToProps (state, props) {
if (skipWhileLoading && state.app.getIn(['loader', '_loading'])) {
return {}
} else {
return _.reduce(selectors, (result, selector) => {
return _.extend(result, selector(state, props))
}, {})
}
}
}
import React from 'react'
import _ from 'lodash'
import getDisplayName from 'core/lib/getDisplayName'
/**
* Decorator that converts immutable props to plain JS.
* Optionally, filter which props get converted.
*/
export default function toJS (propsMap) {
function decorate (Component) {
class ToJS extends React.Component {
static displayName = `ToJS(${getDisplayName(Component)})`
static WrappedComponent = Component
toJS () {
return _.reduce(this.props, (result, value, key) => {
if (propsMap) {
if (propsMap[key]) {
result[propsMap[key]] = (typeof value.toJS === 'function') ? value.toJS() : value
} else {
result[key] = value
}
} else {
result[key] = (value && typeof value.toJS === 'function') ? value.toJS() : value
}
return result
}, {})
}
render () {
return (
<Component {...this.toJS()}/>
)
}
}
return ToJS
}
// Support `true`.
if (propsMap === true) {
propsMap = null
}
// Support an array of propNames.
if (_.isArray(propsMap)) {
propsMap = _.keyBy(propsMap, _.identity)
}
// Support using @toJS or @toJS(propsMap)
if (_.isPlainObject(propsMap) || !propsMap) {
return decorate
} else {
return decorate.apply(null, arguments)
}
}
@yairEO
Copy link

yairEO commented Sep 15, 2018

There's way too much code here for a beginner to grasp how to use a simple route decorator..
May I suggest you consider opening another gist with a much simpler codebase? maybe 2-3 files with as little code in them as possible...
Just an App.jsx and some child component and of course the decorator itself.
Thanks!

@poksme
Copy link

poksme commented Apr 15, 2019

Thanks for sharing that, I have turned this into a package : https://www.npmjs.com/package/react-redux-immutable

  1. Install
npm install --save react-redux-immutable
  1. Use as a drop-in replacement
- import {connect} from 'react-redux'
+ import {connect} from 'react-redux-immutable'

That's it your mapStateToProps function can return immutable objects and your connected React component will receive JS objects.

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