StatefulRouteComponent for Use with React Router and Redux
The framework, tool, and component options available for use in client-side Javascript applications are changing rapidly these days. It is rare to build with the same framework for any three significant projects in succession, and what was commonplace two years ago is already vanishing today. This year at least, a sane choice of component parts for a Javascript front-end application involves Redux for application state management, React for rendering views made up of containers (which tend to interact with application state) and components (which do not), and React Router to manage the structure of views and browser location via the HTML5 history API. Linking Redux to React Router is accomplished via redux-simple-router, which does just the one thing, creating the link, and otherwise stays out of the way of both Redux and React Router.
If you follow the example code provided in the documentation for these various projects, then this all works just fine for any application in which the browser location maps exactly to the displayed state. The user changes the location, React Router triggers adjustment of the displayed React components, and the view is rendered according to the Redux state provided to the components. User interaction with a view works via Redux actions and reducers in the normal way to alter the state, and that alteration includes updating the location. The location update is passed to React Router via redux-simple-router, and that prompts React Router to manage the view updates via React.
Where this doesn't work is for the much more common case in which location doesn't map exactly to the displayed state. For example, the location may correspond to a view of changing data provided by the server. The view includes tools to interact with that data, which might be as simple as a refresh button. The user can update both data and application state in a potentially complex way without leaving the view and without changing the location. Redux can manage this handily, keeping the state updated appropriately, but the combination of React Router and redux-simple-router out of the box cannot keep up with changes in application state that occur independently of location, and will fail to render via React correctly in that situation.
This is probably best illustrated by example. Consider an application with this setup and routing definition:
// NPM. import * as history from 'history'; // We have to import React to make render work even though it is not used // explicitly. import React from 'react'; // eslint-disable-line no-unused-vars import * as ReactDOM from 'react-dom'; import * as ReactRedux from 'react-redux'; import * as ReactRouter from 'react-router'; import * as reduxSimpleRouter from 'redux-simple-router'; import * as redux from 'redux'; // Local application code and components. import AppLayout from './container/appLayout'; import InvalidRouteView from './container/invalidRouteView'; import * as reducers from './reducers'; import initialState from './initialState'; import ViewOne from './container/viewOne'; import ViewTwo from './container/viewTwo'; const pathHistory = history.createHistory(); // This is the react-simple-router boilerplate needed to connect // the history and store so that things are kept in sync. const reduxRouterMiddleware = reduxSimpleRouter.syncHistory(history); const createStoreWithMiddleware = redux.applyMiddleware( reduxRouterMiddleware )(function (reducer) { return redux.createStore(reducer, initialState); }); // Create the reducer and store. const reducer = redux.combineReducers({ ...reducers, routing: reduxSimpleRouter.routeReducer }) const store = createStoreWithMiddleware(reducer); // Connect the top level of the application component tree // to the Redux store and provide it the whole state. const ConnectedAppLayout = ReactRedux.connect(function (state) { return state; })(AppLayout); // The Provider wraps the router and adds the store to the context. // Via this it provides the state as properties to the ConnectedAppLayout. const provider = ( <ReactRedux.Provider store={ store }> <ReactRouter.Router history={ pathHistory }> <ReactRouter.Route path="/" component={ ConnectedAppLayout }> <ReactRouter.Route path="one" component={ ViewOne } /> <ReactRouter.Route path="two" component={ ViewTwo } /> <ReactRouter.Route path="*" component={ InvalidRouteView } /> </ReactRouter.Route> </ReactRouter.Router> </ReactRedux.Provider> ); ReactDOM.render(provider, jQuery('#app')[0]);
In this arrangement React Router breaks the chain of rendering between AppLayout and its children. Redux ensures that the React update lifecycle, and thus AppLayout.render(), is invoked in response to any change in application state, but that will only also update the Route components AppViewOne and AppViewTwo if the location changed such that one Route component is unmounted and the other mounted in its place. State changes internal to a Route component will not automatically result in a React update and rendering as things stand.
So, how to sort out this issue without breaking the basic model of a central store of application state and the circle of one-way data flow managed by Redux? The documentation in all of these various projects is surprisingly opaque on this topic. One possibility is to use ReactRedux.connect() on the view components:
// Connect the top level of the application component tree // to the Redux store and provide it the whole state. const ConnectedAppLayout = ReactRedux.connect(function (state) { return state; })(AppLayout); // Also connect the views so that Redux will take care of // rendering them on state updates. Here each of the views // gets the whole state, but that may or may not be the way // in which other applications are organized. Dividing up // the state object by view works fairly well in some cases. const ConnectedViewOne = ReactRedux.connect(function (state) { return state; })(ViewOne); const ConnectedViewTwo = ReactRedux.connect(function (state) { return state; })(ViewTwo); const ConnectedInvalidRouteView = ReactRedux.connect(function (state) { return state; })(InvalidRouteView); // The Provider wraps the router and adds the store to the context. // Via this it provides the state as properties to the ConnectedAppLayout. const provider = ( <ReactRedux.Provider store={ store }> <ReactRouter.Router history={ pathHistory }> <ReactRouter.Route path="/" component={ ConnectedAppLayout }> <ReactRouter.Route path="one" component={ ConnectedViewOne } /> <ReactRouter.Route path="two" component={ ConnectedViewTwo } /> <ReactRouter.Route path="*" component={ ConnectedInvalidRouteView } /> </ReactRouter.Route> </ReactRouter.Router> </ReactRedux.Provider> );
The Redux documentation suggests that nesting connected components is not a good idea, however, as it can muddy the picture when tracing the flow of data. Connect once at the top, and that is that. So, after some digging, I settled on the alternative approach of a view component superclass that listens on the Redux store and forces its own React update for any state update in which the location did not change. That looks much as follows:
// NPM. import React, { PropTypes } from 'react'; export default class StatefulRouteComponent extends React.Component { /** * Where the location object is in the store depends on the setup of * the reducers. In the example above, it is in the location defined * in this method, but that doesn't have to be the case. */ getCurrentLocation () { const state = this.store.getState(); try { return state.routing.location; } catch { return null; } } /** * Use this to set up a listener on the store and force an update when the * store updates but the location does not. * * If the location does update, then the router will correctly deal with * the component render, so we don't have to handle that case. */ componentWillMount() { // Assume that ReactRedux.connect() has been used on a parent component, // and thus the store is available in the context. this.store = this.context.store; this.lastLocation = this.getCurrentLocation(); this.unsubscribeStoreListener = this.store.subscribe(() => { let location = this.getCurrentLocation(); // Even for hash history, the pathname is used to store the path. // // If the store updates but the location hasn't changed, we want to // render this component. Rendering will otherwise only happen when // the location changes. if ( location && this.lastLocation && (location.pathname === this.lastLocation.pathname) ) { this.forceUpdate(); } this.lastLocation = location; }); } componentWillUnmount() { // We have to remove the store listener when the component goes away, // otherwise bad things can happen. At the least, React complains about // it. this.unsubscribeStoreListener(); } } // This enforces the application context contents. StatefulRouteComponent.contextTypes = { // Provided by react-redux via connect and Provider. store: PropTypes.object.isRequired };
In this scenario, ViewOne and ViewTwo can now be written as classes that extend StatefulRouteComponent rather than React.Component, and everything just works again.