Showing Custom UI on React Router Transitions
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"} />
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!
- 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 recommendcuid
for real world usage). - Next, define our HOC with some basic state. It takes in a
when
prop just likePrompt
(and passes it down). It also has a simpleopen
flag. - When our HOC mounts, it registers our
show
trigger globally. It cleans up in case it is unmounted. - 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
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!