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 front end developer, open source contributor, karaoke enthusiast, and he likes bowler hats. You might be familiar with his project, Feathers UI, an open source user interface library for Starling Framework. You should follow Josh on Twitter.

Leave a Reply

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