Doğacan Bilgili A software developer who is also into 3D Modeling/Animation.

Building a modal module for React with React-Router

8 min read 2405

A guide to building a modal module for React with React-Router.

Modals are very useful for displaying one view on top of another.

However, they are more than an absolutely positioned <div> element wrapping everything when it comes to implementation. Especially if you need dynamic URLs, page refreshes, or a simple scrolling interaction on a mobile device.

In this article, we’ll discuss the various aspects of modals and identify solutions to satisfy the requirements that come with creating dynamic URLs, page refreshes, and other features.

Before starting to shape the modal component, let’s start with some basics of the react-router package.

We’ll use four components from this package: BrowserRouter, Route, Link, and Switch.

Since this is not a react-router tutorial, I won’t be explaining what each of these components do.

However, if you’d like some info about react-router, you can check out this page.

Basic routing

First, go ahead and install react-router-dom through npm.

npm install react-router-dom --save

At the very top level of your application, use the <BrowserRouter/> component to wrap your app.

import { BrowserRouter } from "react-router-dom";

ReactDOM.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById('root')
);

Inside <App/>, you’ll need to specify the routes so that you can render a specific view when one of them — or none of them — match.

Let’s assume we have three different components to render: <Home/>, <About/> and <Contact/>. We’ll create a navigation menu, which will always be visible at the very top of the application.

The <Link/> or <NavLink/> components from react-router-dom are used for navigation purposes, while <NavLink/> has the special feature of being applicable to a specific styling when the current URL matches.

Functionality-wise, you can use either one.

Below is the basic structure of the navigation menu, which changes the URL accordingly:

render() {
  return (
    <div className="app">
      <div className="menu">
        <Link className="link" to='/'>Home</Link>
        <Link className="link" to='/about'>About</Link>
        <Link className="link" to='/contact'>Contact</Link>
      </div>
    </div>
  );
}

The next thing we’ll do is implement the mechanism that matches the URL and renders a specific component.

<Switch/> renders the first matching location specified by its <Route/> children. When nothing is matched, the last <Route/> is returned — usually as a 404 page.

render() {
  return (
    <div className="app">
      <div className="menu">
        <Link className="link" to='/'>Home</Link>
        <Link className="link" to='/about'>About</Link>
        <Link className="link" to='/contact'>Contact</Link>
      </div>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route exact path="/contact/" component={Contact} />
        <Route exact path="/about" component={About} />
        <Route>{'404'}</Route>
      </Switch>
    </div>
  );
}

Creating a modal component

So far, we’ve implemented the basic routing structure. Now we can create a modal component and work on displaying it as an overlay.

Although there are a variety of different methods for creating modal components, we’ll only be covering one.

A modal component has a wrapper element which spans the whole screen — width and height.

This area also acts as a clickedOutside detector. Then the actual modal element is positioned relative to that wrapper element.

An example of a modal router element.

Below is an example of a <Modal/> functional component using withRouter HOC (Higher order component) to access the router history and call the goBack() method to change the application URL when the modal is closed on click to .modal-wrapper.

onClick={e => e.stopPropagation()} is used to prevent propagation of the click event and trigger the onClick on .modal-wrapper, which would close the modal when the actual .modal element is activated.

import React from 'react';
import { withRouter } from 'react-router-dom';

const Modal = () => (
  <div
    role="button"
    className="modal-wrapper"
    onClick={() => this.props.history.goBack()}
  >
    <div
      role="button"
      className="modal"
      onClick={e => e.stopPropagation()}
    >
      <p>
        CONTENT
      </p>
    </div>
  </div>
);

export default withRouter(Modal);

Styling the .modal-wrapper is just as important. Below, you can find the basic styling used to make it span the whole screen and appear above the content.

Using -webkit-overflow-scrolling: touch enables elastic scroll on iOS devices.

.modal-wrapper {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100vh;
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
}

Opening the modal view

The modal component we created should render on top of the existing view when a specific URL is matched, meaning that somehow we have to change the URL so the routing mechanism can decide what to render.

We know that <Switch/> renders the first matching location, but a modal overlay needs two <Route/> components rendering at the same time.

This can be achieved by putting the modal <Route/> out of <Switch/> and rendering it conditionally.

In this case, we should be able to detect if a modal is active or not.

The easiest way to do this is by passing a state variable along with a <Link/> component.

In the same way we used the <Link/> component to create the navigation menu, we’ll use it to trigger a modal view.

The usage shown below lets us define a state variable, which is then made available in the location prop, which we can access within any component using withRouter HOC.

<Link
  to={{
    pathname: '/modal/1',
    state: { modal: true }
  }}
>
  Open Modal
</Link>

Put this anywhere you want. Clicking the link will change the URL to /modal/1.

There might be several modals with different names like modal/1, modal/2, and so on.

In this case, you’re not expected to define each <Route/> intended to match the individual modal locations. In order to handle all of them under the /modal route, use the following syntax:

<Route exact path="/modal/:id">

This gives you the flexibility of getting the value of the hardcoded :id parameter within the modal component through the match.params prop.

It also lets you do dynamic content renderings, depending on which modal is open.

Matching the modal location

This section is particularly important because it identifies the mechanism for displaying a modal on top of an existing view even though the location parameter changes when a modal is opened.

When we click the Open Modal link defined in the previous section, it will change the location path to /modal/1, which matches nothing in <Switch/>.

So we have to define the following <Route/> somewhere.

<Route exact path="/modal/:id" component={Modal} />

We want to display the <Modal/> component as an overlay.

However, putting it inside <Switch/> would match it and only render the <Modal/> component. As a result, there would be no overlay.

To resolve this problem, we need to define it both inside and outside of <Switch/> with extra conditions.


Below, you’ll see the modified version of the same snippet. There are several changes. Let’s list them quickly:

  • There is a previousLocation variable defined in the constructor.

  • There is an isModal variable defined, which depends on some other values.

  • <Switch/> is using a location prop.

  • There are two <Route exactpath="/modal/:id" component={Modal} /> used both inside and outside <Switch/>, and the one outside is conditionally rendered.

When a modal is opened, we need to store the previous location object and pass this to <Switch/> instead of letting it use the current location object by default.

This basically tricks <Switch/> into thinking it’s still on the previous location — for example / — even though the location changes to /modal/1 after the modal is opened.

This can be achieved by setting the location prop on <Switch/>.

The following snippet replaces the previousLocation with the current location object when there is no open modal.

When you open a modal, it doesn’t modify the previousLocation.

As a result, we can pass it to <Switch/> to make it think we’re still on the same location, even though we changed the location by opening a modal.

We know that when a modal is opened, the state variable named modal in the location object will be set to true.

We can check if the state of the location object is defined and has the state variable of modal set to true.

However, these two checks alone do not suffice in the case of refreshing the page.

While the modal has to be closed on its own, location.state && location.state.modal still holds.

Checking whether this.previousLocation !== location, we can make sure that refreshing the page will not result in setting isModal to true.

When the modal route is visited directly, which is modal/1 in our example, then none of the checks are true.

Now we can use this boolean value to both render the <Route/> outside of the <Switch/>, and to decide which location object to pass to location prop of <Switch/>.

Given that <Modal/> component has the necessary stylings, this results in two different views rendering on top of each other.

constructor(props){
  super(props);
  this.previousLocation = this.props.location;
}

componentWillUpdate() {
  const { location } = this.props;
  if (!(location.state && location.state.modal)) {
    this.previousLocation = this.props.location;
  }
}  

render() {
  const { location } = this.props;
  const isModal = (
    location.state &&
    location.state.modal &&
    this.previousLocation !== location
  );

  return (
    <div className="app">
      <div className="menu">
        <Link className="link" to='/'>Home</Link>
        <Link className="link" to='/about'>About</Link>
        <Link className="link" to='/contact'>Contact</Link>
      </div>
      <Switch location={isModal ? this.previousLocation : location}>
        <Route exact path="/" component={Home} />
        <Route exact path="/contact/" component={Contact} />
        <Route exact path="/about" component={About} />
        <Route exact path="/modal/:id" component={Modal} />
        <Route>{'no match'}</Route>
      </Switch>
      {isModal
        ? <Route exact path="/modal/:id" component={Modal} />
        : null
      }
    </div>
  );
}

Rendering different modal views

So far we have implemented our modal in a way that ensures we don’t render an overlay when refreshing a page with an open modal, or when directly visiting a modal route.

Instead, we only render the matching <Route/> inside <Switch/>.

In this case, the styling you want to apply is likely to be different, or you might want to show a different content.

This is pretty easy to achieve by passing the isModal variable as a prop on the <Modal/> component, as shown below.

Then, depending on the value of the prop, you can apply different stylings or return a completely different markup.

return (
  <div className="app">
    <div className="menu">
      <Link className="link" to='/'>Home</Link>
      <Link className="link" to='/about'>About</Link>
      <Link className="link" to='/contact'>Contact</Link>
    </div>
    <Switch location={isModal ? this.previousLocation : location}>
      <Route exact path="/" component={Home} />
      <Route exact path="/contact/" component={Contact} />
      <Route exact path="/about" component={About} />
      <Route exact path="/modal/:id" component={Modal} />
      <Route>{'no match'}</Route>
    </Switch>
    {isModal
      ? <Route exact path="/modal/:id">
          <Modal isModal />
        </Route>
      : null
    }
  </div>
);

Preventing the scroll underneath the modal

When you open the modal on some browsers it may have the content below scrolling underneath the modal, which is not a desirable interaction.

Using overflow: hidden on body is the first attempt to block scrolling on the entire page.

However, although this method works fine on desktop, it fails on mobile Safari since it basically ignores overflow: hidden on body.

There are several different npm packages attempting to remedy this scroll locking issue virtually across all platforms.

I found the body-scroll-lock package quite useful.

From this package, you can import disableBodyScroll and enableBodyScroll functions, which accept a reference to the element for which you want scrolling to persist as an input.

When the modal is open we want to disable scrolling for the entire page, except for the modal itself.

Therefore, we need to call disableBodyScroll and enableBodyScroll functions when the modal component is mounted and unmounted, respectively.

To get a reference to the parent <div> of the modal component, we can use the createRef API from React and pass it as a ref to the parent <div>.

The code snippet below disables scrolling when the modal is open and enables it again when the modal component is about to be unmounted.

Using this.modalRef as the input for these imported functions prevents the content of the modal component from being scroll-locked.

Before using the disableBodyScroll function, we need a simple check.

This is because a modal component might get mounted if the page is refreshed when a modal is open, or when the modal route is visited directly.

In both cases, scrolling should not be disabled.

We have already passed the isModal variable as a prop to the <Modal/> component to render different views, so we can just use this prop to check if there is actually a modal.

Below is the modified version of the modal component:

import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';

class Modal extends Component {
  constructor(props) {
    super(props);
    this.modalRef = React.createRef();
  }
  
  componentDidMount() {
    const { isModal } = this.props;

    if (isModal) {
      disableBodyScroll(this.modalRef.current);
    }
  }
  
  componentWillUnmount() {
    enableBodyScroll(this.modalRef.current);
  }
  
  render() {
    return (
      <div
        ref={this.modalRef}
        className="modal-wrapper"
        onClick={() => this.props.history.goBack()}
      >
        <div
          className="modal"
          onClick={e => e.stopPropagation()}
        >
        </div>
      </div>
    )
  }
}

Conclusion

You now have an understanding of how a modal view works, as well as a sense of some of the problems you may encounter while implementing your own integration.

For the fully functional example, visit this code sandbox project.

Plug: , a DVR for web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Doğacan Bilgili A software developer who is also into 3D Modeling/Animation.

Leave a Reply