Enabling the back button to cancel confirmation dialogs in React apps

Users expect that dialogs, action sheets, alerts, drawers, and other pop-ups will close when the back button is pressed. This is especially true on Android (where the back button is globally available in all apps), but this behavior makes sense for any apps running in a web browser too — on both mobile and desktop. Usually, when a dialog is open, the back button should act the same as if the user had chosen the “Cancel” option. The worst thing that could happen (but it’s surprisingly common, unfortunately) is that the dialog will close and the browser will navigate away from the page that opened the dialog!

In a React app, we can use the browser’s history (exposed by our routing library of choice) to ensure that pressing the back button when a dialog is open closes the dialog and keeps the app on the same screen that opened the dialog. As a bonus, if the user navigates forward again, we can restore the dialog to its previous state very easily. Let’s take a look.

For convenience, I created an example on CodeSandbox to demonstrate the final result. In this example, I’ve added Reach Router and Material UI as dependencies to provide the routing/navigation and some UI controls. The concept that I’m demonstrating here should work just as well with React Router and other UI control libraries without too many changes.


Edit React dialogs with back button

In our example app, we have a list of users. When you click/tap a user’s name, a dialog will pop up with some actions that can be performed. Or you can press cancel to close the dialog. Our goal is to ensure that the browser’s back button will close the dialog without navigating away from the list of all users — the same as if the cancel option were chosen with a click/tap.

We can pass the user to dialog using the browser history’s state object. We’ll push a new state onto the stack (without changing the URL), and the dialog will check the current state to know when it should open or close.

Let’s start by rendering the list of users using some components from Material UI:

// inside render()
<List>
  {users.map(user => {
    return (
      <ListItem
        key={user.id}
        button
        onClick={() => this.selectUser(user)}
      >
        <ListItemIcon>
          <Person />
        </ListItemIcon>
        <ListItemText primary={user.name} />
      </ListItem>
    );
  })}
</List>

We have an array of users that we map to ListItem components. When a list item is clicked/tapped, we call a function called selectUser() that will manipulate the browser’s history.

For reference, a user in the array looks like this:

{ id: 1, name: "Franklin Nelson" }

In selectUser(), we use the navigate() function provided to our component by Reach Router to add the user to the history stack.

selectUser(user) {
  let newState = { selectedUser: user };
  this.props.navigate(this.props.location.pathname, { state: newState });
};

Notice that we use this.props.location.pathname (also provided by Reach Router) to ensure that the URL does not change. We’re staying at the same location, but a new history entry is added that has a different state. This means that when we go back, we’ll stay on the same page.

Next, let’s see how our UserActionsDialog component is rendered.

As a first step, we should check if the current history state has a selected user. If no user is selected, we’ll simply return null because we don’t want to render the dialog:

// inside UserActionsDialog's render()
let user = undefined;
let state = this.props.location.state;
if (state && "selectedUser" in state) {
  user = state.selectedUser;
}
if(!user)
{
  return null;
}

If a user is selected, we should render our dialog. It might look something like this:

// continued from above
return (
  <Dialog open>
    <DialogTitle>{user.name}</DialogTitle>
    <List>
      <ListItem
        button
        onClick={() => alert("Option 1 selected for " + user.name)}>
        <ListItemText primary="Option 1" />
      </ListItem>
      <ListItem
        button
        onClick={() => alert("Option 2 selected for " + user.name)}>
        <ListItemText primary="Option 2" />
      </ListItem>
      <ListItem
        button
        onClick={this.cancelItem_onClick}>
        <ListItemText primary="Cancel" />
      </ListItem>
    </List>
  </Dialog>
);

Notice that we have access to the full user object selected in the parent component. We’re accessing its name field here.

For simplicity, the first two actions simply call alert(). The third option is named “Cancel” and it should close the dialog. Clicking this item calls cancelItem_onClick(), which appears below:

cancelItem_onClick = () => {
  window.history.back();
};

When we call window.history.back(), the selected user is removed from the history stack and our UserActionsDialog component will re-render and return null. That will cause the dialog to be hidden.

Notice that we don’t do anything to cancel except to navigate back. With that in mind, we don’t need to do to do anything more to enable the back button. It just works! In fact, after we navigate back, the browser’s forward button will work too, and the dialog will re-open… including the same selected user.

Back in the parent component that contains our List of users, let’s see how to add our UserActionsDialog:

// this goes after the list of all users
<Router>
  <UserActionsDialog path={this.props.location.pathname} />
</Router>

Notice that we place UserActionsDialog inside a Router component. This ensures that the location prop is passed into the dialog so that we can find the selected user. You can nest routers, so it’s fine if there’s another router further up the component tree.

As I mentioned before, the route’s path is the same as its parent. We shouldn’t need to change the URL when selecting a user because that wouldn’t make much sense if the user wanted to share the page that contains the dialog. Since the current state object stores the user, that’s what we use to determine if the dialog should be open or closed.

Even when using some kind of router, many web apps don’t account for all cases where the back button should behave a certain way. Opening dialogs to select an action is a common place where the user might press the back button to cancel, and you don’t want your app to navigate to a different page unexpectedly. On Android, this is especially important because the global back button is such a core part of the user experience. However, it’s just as useful in a web browser on any platform, including on desktop.

About Josh Tynjala

Josh Tynjala is a frontend software developer, open source contributor, karaoke enthusiast, and he likes bowler hats. Josh develops Feathers UI, a user interface component library for creative apps, and he contributes to OpenFL. One of his side projects is Logic.ly, a digital logic circuit simulator for education. You should follow Josh on Mastodon.

Discussion

  1. Yan Zhu

    Hey nice tutorial. Just one thing, IMO after ‘cancel’, if one wants to undo it, he would go backward instead of forward. So I think it is not a good choice to implement the cancel action with window.history.back().

    1. Josh Tynjala

      Calling `window.history.back()` behaves the same as if the user pressed the browser’s native back button. Our app can’t really do anything to prevent the user from going back in history. I’d rather both the native back button and my dialog’s Cancel button behave the same way.

      To be honest, though, I’m not sure what you mean when you say “if one wants to undo it”. What is “it” in that situation? The “cancel” action? In that case, the user should be able to perform the same action that caused the dialog to appear in the first place. Or they could even press the browser’s native forward button. That works because we set the state in the history stack, so the state would be restored.

      1. Yan Zhu

        Hey Josh, sorry there is no notification so I just noticed your reply “accidentally “…XD

        First, yes your assumption is right.

        Secondly, what I meant was:
        If the user wants to reopen the dialog after closing it by pressing the “cancel” button, IMO he would intuitively press “back” button, instead of “forward”.

        Because pressing back button and clicking cancel button are cognitively different. One is going one step back , while the other is executing a new action.

        So there actually have 2 different flows:
        A. open the dialog ( new action_1 ) -> press back button to close the dialog (undo the action_1 ) -> press forward button to open the dialog (redo the action_1)
        B. open the dialog ( new action_1 ) -> press cancel button to close the dialog (new action_2) -> press back button to open the dialog (undo the action_2)

        What do you think?

  2. Pulkit Aggarwal

    Hi, Thanks for this post. This post will be very helpful for those who are using Reach-Router library. But in my app, I am using React-Router library, I tired the same concept which you are using in this example. But not able to solve this issue. When my component renders at the same time my popup component rendered too. And on click when I push that location it’s open that but when I close it goes back to the previous URL. Can you help me find this or can you provide the example for React-Router too.

    1. Jon Hobbs

      I’m also struggling because I’m trying to use React Router 6 which I realise is still in Alpha but by the time my app is finished it will be the standard so I started using it now.

      The standard method for React Router is to use a route outside of the switch statement to make the modal part of the URL…

      https://stackoverflow.com/a/51383026

      But V6 gets rid of the Switch so it gets a bit confusing.

  3. josh

    So, if the user “open and closes” several modal boxes repeatedly (or sidebars for that matter). Each and every of those actions is going to be pushed to the stack? hence clicking the back button several times will repeat all of those steps? because that would be anoying; instead of just closing the last modal opened…

  4. josh

    I confirmed this is the case. Let’s say there are 2 toggle butttons for 2 sidebars respectively (left and right). toggle 1 (left opens), toggle 2 (left closes, right opens), “browser back button” (right closes, left opens WTF? ). This is a nighmare.

    Isn’t there other way? perhaps POPing the last in history?

    1. Josh Tynjala

      That sounds like the right behavior to me. However, if you don’t want that middle state, it sounds like you need to replace the state when the left sidebar is open and toggle 2 is pressed. Push is not the only option. Replace is also available. You may need to do it conditionally, though, by checking if either sidebar is open.

  5. josh

    I thought about the same. This is key to a wider approach.
    It does not apply to your example, because in your UI you’re forcing a close after an open, but that’s a narrowed down implementation.

    1. Josh Tynjala

      Yes, history on the web tends to “remember” more than native apps because, I guess, users expect it. Generally, for every click you make, you can expect that many times pressing back to return to a previous state.

      That being said, like I mentioned, it’s also possible to replace the current state in the history so that you don’t need to go back that many times. Routers generally provide a way to trigger the lower-level API that handles that.

Leave a Reply to josh Cancel reply

Your email address will not be published. Required fields are marked *