Observable-based routing

Imagine your app has a multi-step wizard such as a shopping cart checkout with a variety of error and special case screens. It is very convenient to have your observable store's business logic decide what state it should be in and to have the UI render the appropriate screen.

At the same time, you still need to allow the user to navigate back or between screens manually, so the observable state must not force a particular screen to be displayed.

To accomplish this with mobx and react-router 3.x you can use an autorun reaction in a parent component that forces a re-route only when the observable changes but not when the user navigates between child components.

In MobX 3.x/Mobx-React 4.x, there is a bit of a trick to doing this correctly, so below is an example that re-routes based on the value of some store.stage observable.

WizardRouter.jsx:

import React from 'react';
import { Router, Route, hashHistory} from 'react-router';
import store from './store';
import Wizard from './Wizard';
// some screens:
import Start from './Start';
import Error from './Error';
import Select from './Select';

class WizardRouter extends React.Component {

  // Determines which screen to show on initial load
  redirectToDefault = (nextState, replace) => {
    if (nextState.location.pathname === '/wizard') {
      replace(this.selectRoute() || '/wizard/start');
    }
  };

  // This method is also provided as selectRoute handler to autorun in Wizard.jsx
  selectRoute = () => {
    switch (store.stage) {
      case store.STAGES.error:
        return '/wizard/error';
      case store.STAGES.select:
        return '/wizard/select';
    }
    return null;
  };

  render() {
    return (
      <Router history={hashHistory}>
        <Route path="/wizard"
          component={Wizard}
          onEnter={this.redirectToDefault}
          selectRoute={this.selectRoute}
        >
          <Route path="start" component={Start} />
          <Route path="error" component={Error} />
          <Route path="select" component={Select} />
        </Route>
     </Router>
    );
  }
}

export default WizardRouter;

Wizard.jsx:

import React from 'react';
import { routerShape } from 'react-router/lib/PropTypes';
import { autorun, action } from 'mobx';
import { observer } from 'mobx-react';
import store from './store';

let autorunDisposer;

@observer class Wizard extends React.Component {
  componentWillMount() {
    /*
     In MobX 3.x/Mobx-React 4.x, @observer components' props are made
     observable, so referencing this.props.route.selectRoute inside the
     autorun would cause it to fire not only when store.stage changes but when
     location changes, thus making user navigation between screens impossible.
     So, dereference early to make it NOT observable.
     */
    const selectRoute = this.props.route.selectRoute;

    autorunDisposer = autorun(() => {
      const path = selectRoute();

      // any observable accessed inside the action will
      // not cause this autorun to re-run if it changes
      action(() => {
        if (path && !this.props.router.isActive(path)) {
          this.props.router.push(path);
        }
      })();
    });
  }

  componentWillUnmount() {
    autorunDisposer && autorunDisposer();
  }

  render() {
    return (
      <div>
        {this.props.children}
      </div>
    );
  }
}

Wizard.propTypes = {
  children: React.PropTypes.element,
  router: routerShape, // React-Router provides this automatically
  // props provided to Route component are available here:
  route: React.PropTypes.shape({
    selectRoute: React.PropTypes.func,
  }),
};

export default Wizard;

Last updated