Showing Custom UI on React Router Transitions
Using the React Router `<Prompt />` component you can trigger custom UI to prevent navigation

Published on Thursday, July 26, 2018

We had a recent requirement on our project to prompt the user with a custom modal when they have some pending changes to save in our application. The intention was to ensure they saved their changes as it affected other parts of the data they're working on and we need to recompute business rules (fun, huh?).

Since we're leveraging React Router v4, I looked into what was provided out of the box to see how customizable it was.

The Prompt Component

React Router core has a component called Prompt. The purpose of this component is to show a dialog to the user during a router transition. The when prop can be set to enable/disable this functionality dynamically which is great. You can also pass a message that will get displayed or a function that takes a location and returns true to allow navigation or a message to prevent.

<Prompt when={true} message="Please save your changes before proceeding" />
<Prompt when={true} message={location => location.pathname === "/foo" ? true : "Denied"} />

The default prompt

However, I noticed a few caveats with this component. First, the default prompt is the browser prompt and when you click 'Okay' it allows navigation which is exactly the opposite of what I want. Second, you can't render custom UI directly through Prompt--it just takes a message. Why? If you dive down into the code, it leverages the history npm package which just delegates to that browser prompt. The good news is, the history package allows you to override the UI generation via getUserConfirmation.

import createHistory from "history/browserHistory";

const history = createHistory({
  getUserConfirmation(message, callback) {
    // Show some custom dialog to the user and call
    // callback(true) to continue the transiton, or
    // callback(false) to abort it.
  }
});

This is helpful, using this we can always prevent the transition if needed. So, given that we can't use Prompt directly to render custom React UI but we can override the core handler for showing the prompt, is there a way to connect the two? Of course.

Overriding getUserConfirmation

First, let's test our assumption out and see if we can log the message we get from Prompt.

Using the regular BrowserRouter component from react-router-dom, pass the getUserConfirmation prop:

const getUserConfirmation = (message, callback) => {
  console.log(message);
  callback(false);
}

<BrowserRouter getUserConfirmation={getUserConfirmation}>
...
</BrowserRouter>

If like me you are using connected-react-router, we can still customize the createHistory call as shown above:

...
import { createBrowserHistory } from 'history'
import { applyMiddleware, compose, createStore } from 'redux'
import { connectRouter, routerMiddleware } from 'connected-react-router'
...
const history = createBrowserHistory({
  getUserConfirmation(message, callback) {
    console.log(message);
    callback(false);
  }
})

const store = createStore(
  connectRouter(history)(rootReducer), // new root reducer with router state
  initialState,
  compose(
    applyMiddleware(
      routerMiddleware(history), // for dispatching history actions
      // ... other middlewares ...
    ),
  ),
)

Then our Prompt:

// MyComponent.js
<Prompt when={true} message="Please save your changes before proceeding" />

If you set this up, when you try to navigate away from the page, you will not be able to and the message will be logged to the console.

Great! Now you may be thinking, "We can just pass anything and it'll get passed through!" But you'd be wrong because that's what I thought too.

const MyCustomDialogComponent = () => <div />

<Prompt
  when={true}
  message={MyCustomDialogComponent}
/>

If you try this, nothing will be logged to the console and navigation will not be blocked. React Router uses prop-types to validate message is a string (phooey!).

So, we have to stick with strings. Is there another way besides doing a bunch of work to add support for this in react-router directly (which'd be neat!)?

Using a HOC with a Global Symbol-based Trigger

I landed on this approach as it seemed to be the less hacky way to achieve this. Essentially, since we can pass a string only to the getUserConfirmation, I pass in the key for a global Symbol and store a global trigger to signal to the React dialog component to show.

If you haven't used Symbol before, it is a primitive type in JavaScript introduced in ES6. What's neat about them is you can "register" them globally and they will be able to be looked up from other modules loaded within the same "code realm" (roughly the execution context of the engine). Well-known global symbols are built-in like Symbol.iterator.

Why a Symbol vs. just a regular string? A Symbol-based property won't be enumerable (like using Object.keys) so it's kind of a way to do basic "private" properties. They can still be enumerated with other methods like Object.getOwnPropertySymbols. It creates a nice barrier between things your app might care about and walls it off unless someone explictly asks for that property.

So what does this end up looking like? We create a HOC that has basic state and a method that will show the dialog when triggered (user attempts to navigate away). The getUserConfirmation handler will receive the Symbol key and invoke the callback on the global object (window) with that Symbol property.

// PreventNavigationDialog.js
class PreventNavigationDialog extends React.Component {

  state = { open: false };

  constructor() {
    super();

    // NOTE: Don't actually use Date.now. In the example
    // repo, I use the `cuid` package
    this.__trigger = Symbol.for(`__PreventNavigationDialog_${Date.now()}`);
  }

  componentDidMount() {
    window[this.__trigger] = this.show;
  }

  componentWillUnmount() {
    delete window[this.__trigger];
  }

  render() {
    const { when } = this.props;
    const { open } = this.state;

    return (
      <React.Fragment>
        <Prompt when={when} message={Symbol.keyFor(this.__trigger)} />
        {open && <div onClick={this.close}>Test dialog</div>}
      </React.Fragment>
    );
  }

  show = allowTransitionCallback => {
    this.setState({ open: true }, () => allowTransitionCallback(false));
  };

  close = () => {
    this.setState({ open: false });
  };
}
// index.js

const getUserConfirmation = (dialogKey, callback) => {

  // use "message" as Symbol-based key
  const dialogTrigger = window[Symbol.for(dialogKey)];

  if (dialogTrigger) {
    // delegate to dialog and pass callback through
    return dialogTrigger(callback);
  }

  // Fallback to allowing navigation
  callback(true);
}

<BrowserRouter getUserConfirmation={getUserConfirmation}>
...
</BrowserRouter>

Okay, a bit going on here!

  1. First, define our shared Symbol. Using Symbol.for will register the Symbol globally so it can be accessed across the page. We assign it a unique key for lookup (I recommend cuid for real world usage).
  2. Next, define our HOC with some basic state. It takes in a when prop just like Prompt (and passes it down). It also has a simple open flag.
  3. When our HOC mounts, it registers our show trigger globally. It cleans up in case it is unmounted.
  4. Modify getUserConfirmation to check and see if a dialog trigger callback exists and call it if so. Since we assign it when the HOC mounts, it will set the state of the dialog.

The nice thing about this design is that we're allowing for multiple potential dialog prompts across the app. We can even have multiple instances of this component without conflict using the uniqueness nature of Symbols. I'm not a huge fan of using window but it has its uses--it's tough to avoid something due to how we need to access it in getUserConfirmation and we can't pass anything but a string.

Demo and Code

End result

You can play with the fully working CRA-based demo with Material UI on CodeSandbox or the corresponding GitHub repo. The demo has a few more features like passing title and message content as well as using the (location: Location) => boolean | string overload that Prompt message prop supports to decide whether to allow transitions based on target location (we use this for our app).

It wouldn't take much to change this HOC to leverage the render props pattern, for example, to show whatever you want by passing down show. Reuse it across your app!

This was an interesting issue to solve and it wasn't as easy as I hoped initially. I hope this helps other people! This could probably be packaged up as a customizable HOC with a little work. Maybe when I have a spare moment I'll stream making a package out of this. Remember to follow me on Twitch!

About Kamran
Hi, I'm a professional full-stack developer who also loves to write, speak at conferences, work on side projects, contribute to open source, make games, and play them.
comments powered by Disqus