React router modal-only routes

Roman K picture Roman K · Mar 1, 2015 · Viewed 13.1k times · Source

I do have a public and a private area in my app and i want to be able to show the login modal dialog everywhere in the public view. The modal should have its own route. A second use case would be a profile modal in the private area.

The problem is that the current view in the background would disappear when the modal is shown because the modal is not a child of the current views route.

Since i do not want to add the same modal to every possible view route, here is the question: Is it possible to decouple modal routes from its parent routes and show them everywhere in the app without the main content rendering? What is the best approach for this? I've found this issue but is seems not to be the same problem.

A browser refresh on the modal route would render nothing in the background but that is a problem i could live with.

Answer

Michelle Tilley picture Michelle Tilley · Oct 4, 2015

I think your best option is probably to utilize either the hidden state, or query strings (for permalinks), or both, especially if a modal (e.g. a login modal) could be displayed on any page. Just in case you're not aware, React Router exposes the state part of the history API, allowing you to store data in the user's history that's not actually visible in the URL.

Here's a set of routes I have in mind; you can jump straight into the working example on JSBin if you want. You can also view the resulting example app in its own window so you can see the URLs changing (it uses the hash location strategy for compatibility with JSBin) and to make sure refreshing works as you'd expect.

const router = (
  <Router>
    <Route component={LoginRedirect}>
      <Route component={LocationDisplay}>
        <Route path="/" component={ModalCheck}>
          <Route path="/login" component={makeComponent("login")} />
          <Route path="/one" component={makeComponent("one")} />
          <Route path="/two" component={makeComponent("two")} />
          <Route path="/users" component={makeComponent("users")}>
            <Route path=":userId" component={UserProfileComponent} />
          </Route>
        </Route>
      </Route>
    </Route>
  </Router>
);

Let's investigate these routes and their components.

First of all, makeComponent is just a method that takes a string and creates a React component that renders that string as a header and then all its children; it's just a fast way to create components.

LoginRedirect is a component with one purpose: check to see if the path is /login or if there is a ?login query string on the current path. If either of these are true, and the current state does not contain the login key, it sets the login key on the state to true. The route is used if any child route is matched (that is, the component is always rendered).

class LoginRedirect extends React.Component {
  componentWillMount() {
    this.handleLoginRedirect(this.props);
  }

  componentWillReceiveProps(nextProps) {
    this.handleLoginRedirect(nextProps);
  }

  handleLoginRedirect(props) {
    const { location } = props;
    const state = location.state || {};
    const onLoginRoute = location.query.login || location.pathname === "/login";
    const needsStateChange = onLoginRoute && !state.login;
    if (needsStateChange) {
      // we hit a URL with ?login in it
      // replace state with the same URL but login modal state
      props.history.setState({login: true});
    }
  }

  render() {
    return React.Children.only(this.props.children);
  }
}

If you don't want to use query strings for showing the login modal, you can of course modify this component to suit your needs.

Next is LocationDisplay, but it's just a component I built for the JSBin demo that displays information about the current path, state, and query, and also displays a set of links that demonstrate the app's functionality.

The login state is important for the next component, ModalCheck. This component is responsible for checking the current state for the login (or profile, or potentially any other) keys and displaying the associated modal as appropriate. (The JSBin demo implements a super simple modal, yours will certainly be nicer. :) It also shows the status of the modal checks in text form on the main page.)

class ModalCheck extends React.Component {
  render() {
    const location = this.props.location;
    const state = location.state || {};
    const showingLoginModal = state.login === true;
    const showingProfileMoal = state.profile === true;

    const loginModal = showingLoginModal && <Modal location={location} stateKey="login"><LoginModal /></Modal>;
    const profileModal = showingProfileMoal && <Modal location={location} stateKey="profile"><ProfileModal /></Modal>;

    return (
      <div style={containerStyle}>
        <strong>Modal state:</strong>
        <ul>
          <li>Login modal: {showingLoginModal ? "Yes" : "No"}</li>
          <li>Profile modal: {showingProfileMoal ? "Yes" : "No"}</li>
        </ul>
        {loginModal}
        {profileModal}
        {this.props.children}
      </div>
    )
  }
}

Everything else is fairly standard React Router stuff. The only thing to take note of are the Links inside LocationDisplay that show how you can link to various places in your app, showing modals in certain circumstances.

First of all, you can of course link (and permalink) to any page asking it to show the login modal by using the login key in the query string:

<Link to="/one" query={{login: 1}}>/one?login</Link>
<Link to="/two" query={{login: 1}}>/two?login</Link>

You can also, of course, link directly to the /login URL.

Next, notice you can explicitly set the state so that a modal shows, and this will not change the URL. It will, however, persist in the history, so back/forward can be used as you'd expect, and a refresh will show the modal over top the same background page.

<Link to="/one" state={{login: true}}>/one with login state</Link>
<Link to="/two" state={{login: true}}>/two with login state</Link>

You can also link to the current page, adding a particular modal.

const path = props.location.pathname;

<Link to={path} state={{login: true}}>current path + login state</Link>
<Link to={path} state={{profile: true}}>current path + profile state</Link>

Of course, depending on how you want your app to work, not all of this is applicable or useful. For example, unless a modal is truly global (that is, it can be displayed no matter the route), this may work fine, but for modals like showing the profile of a given user, I'd probably make that a separate route, nesting it in the parent, e.g.:

<Route path="/users/:id" component={UserPage}>
  <Route path="/users/:id/profile" component={UserProfile} />
</Route>

UserProfile, in this case, would be a component that renders a modal.

Another example where you may want to make a change is storing certain modals in the history; if you don't want to, use replaceState instead of setState as appropriate.