DEV Community

Cover image for How to make routable modals in react with react-router
Mayowa Ojo
Mayowa Ojo

Posted on • Edited on

How to make routable modals in react with react-router

Introduction

Modals are an integral part of web apps. They come in handy when you want to display content that isn't necessarily large enough to take up its own page or content that depends on the current view but ideally should be standalone to give it more emphasis. Because modals are essentially an overlay on a parent page, it makes them tricky to add to our routing system. In this article your're to learn how to add route navigation to your modals.

Prerequisite

Basic knowledge of react, react hooks, and react-router.
React >=16.8

We're going to start by creating a simple react application which displays a list of contacts. You can set up your application locally with create-react-app or for convenience, use an online playground like codesanbox or stackblitz. I'm using stackblitz and there'll be a link to the playground at the end of the post.

Our react app has 4 components (Home, Contacts, Card and Modal). The Home component just renders a welcome text and a link to the contacts page. The Contacts component renders a list of cards and the Card component in turn contains a link to the modal. I'll be using tailwindcss for styling, again for convenience since the focus of this article is on react-router. Let's create a router component and add the home and contacts page.
App.js

import React from "react";
import {
  Switch,
  Route,
  useLocation
} from "react-router-dom";
import Home from "./Home";
import Contacts from "./Contacts";

export default function App() {
  return (
    <div className="w-full bg-gray-200 px-4 relative">
      <Switch>
        <Route path="/" exact component={Home} />
        <Route path="/contacts" exact component={Contacts} />
      </Switch>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

index.js

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";

import App from "./App";

ReactDOM.render(
  <Router>
    <App />
  </Router>,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

We put our Router in the index.js file instead of App.js because we would later need to access some hooks in App.js which will cause errors if we did otherwise.

Home.js

import React from "react";
import { Link } from "react-router-dom";

const Home = () => {
  return (
    <div className="w-full h-screen flex flex-col justify-center items-center">
      <h1 className="text-center text-3xl text-gray-600 font-medium">Welcome!</h1>
      <Link to="/contacts">
        <button className="rounded-lg bg-indigo-400 px-4 py-2 mt-4 text-white font-bold hover:bg-indigo-500">Contacts</button>
      </Link>
    </div>
  )
}

export default Home;
Enter fullscreen mode Exit fullscreen mode

The Home component is very simple. We use a Link component from react-router to navigate to the contacts page.

Contacts.jsx

import React from "react";
import Card from "./Card";

const Contacts = () => {
  return (
    <div className="pt-16 w-full">
      <h1 className="text-2xl font-semibold text-gray-600 text-center">Contacts</h1>
      <div className="flex justify-center flex-wrap mt-8">
        {Array(6).fill().map(() => <Card />)}
      </div>
    </div>
  );
};

export default Contacts;
Enter fullscreen mode Exit fullscreen mode

In the contacts page, we render a list of cards. Since we don't have any actual data we just create an arbitrary array of size 6 and fill it with undefined so we can map to Card components.

Card.jsx

import React from "react";
import { Link } from "react-router-dom";

const Card = () => {
  return (
    <div className="w-56 pb-2 mt-8 mx-4 bg-white rounded-md border border-gray-200 overflow-hidden shadow-lg">
      <Link
        to="/contact/andrew-garfield">
        <div className="flex flex-col items-center py-4 px-2 bg-gray-300">
          <span className="w-10 h-10 rounded-full overflow-hidden inline-block">
            <img
              src="https://uifaces.co/our-content/donated/gPZwCbdS.jpg"
              alt=""
            />
          </span>
          <h1 className="text-lg font-medium text-gray-600 mt-2">
            Andrew Garfield
          </h1>
          <p className="text-sm text-gray-600">Project Manager</p>
        </div>
      </Link>
      <div className="px-2 py-2">
        <p className="text-sm text-gray-600 mt-1 flex items-center">
          <svg
            className="w-4 h-4 mr-2"
            fill="currentColor"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              fill-rule="evenodd"
              d="M6 6V5a3 3 0 013-3h2a3 3 0 013 3v1h2a2 2 0 012 2v3.57A22.952 22.952 0 0110 13a22.95 22.95 0 01-8-1.43V8a2 2 0 012-2h2zm2-1a1 1 0 011-1h2a1 1 0 011 1v1H8V5zm1 5a1 1 0 011-1h.01a1 1 0 110 2H10a1 1 0 01-1-1z"
              clipRule="evenodd"
            />
            <path d="M2 13.692V16a2 2 0 002 2h12a2 2 0 002-2v-2.308A24.974 24.974 0 0110 15c-2.796 0-5.487-.46-8-1.308z" />
          </svg>
          Voyance
        </p>
        <p className="text-sm text-gray-600 mt-1 flex items-center">
          <svg
            className="w-4 h-4 mr-2"
            fill="currentColor"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
            <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
          </svg>
          andrew@hey.com
        </p>
        <p className="text-sm text-gray-600 mt-1 flex items-center">
          <svg
            className="w-4 h-4 mr-2"
            fill="currentColor"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              fill-rule="evenodd"
              d="M7 2a2 2 0 00-2 2v12a2 2 0 002 2h6a2 2 0 002-2V4a2 2 0 00-2-2H7zm3 14a1 1 0 100-2 1 1 0 000 2z"
              clipRule="evenodd"
            />
          </svg>
          +440-344-45-577
        </p>
        <p className="text-sm text-gray-600 mt-1 flex items-center">
          <svg
            className="w-4 h-4 mr-2"
            fill="currentColor"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
            <path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
          </svg>
          @andrew_garfield
        </p>
      </div>
    </div>
  );
};

export default Card;
Enter fullscreen mode Exit fullscreen mode

The Card component contains a Link that navigates to the modal which just displays the a single card but the content is not the focus. Let's now create the logic to make a routable modal.

For our modal to be routable we need to render it in a route component but also make sure that we don't leave the current page when navigating to the modal. We want it to behave like a sub-route. Let's update our App.js
App.js

import React from "react";
import {
  BrowserRouter as Router,
  Switch,
  Route,
  useLocation
} from "react-router-dom";
import Home from "./Home";
import Contacts from "./Contacts";
import Modal from "./Modal";
import "./style.css";

export default function App() {
  const location = useLocation();
  const background = location.state && location.state.background;

  return (
    <div className="w-full bg-gray-200 px-4 relative">
      <Switch location={background || location}>
        <Route path="/" exact component={Home} />
        <Route path="/contacts" exact component={Contacts} />
      </Switch>

      {background && <Route path="/contact/:name" children={<Modal />} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We've introduced two new things; location and background. location is an object that contains url information which we get from the useLocation hook. It updates to the new url whenever we navigate to a new page. background however represents the location state that we were right before we navigate to the modal. Remember we don't actually want to leave the current page, that's why we don't render the Route component for the modal in the Switch component, rather we put it outside and conditionally render depending on the value of background.

What this means is that if there's a background state (it suggests that we are routing to a modal and we don't want to leave the current page), then use the background state as the location for the Switch so that we can still show the current page behind the modal. You should notice that we're passing a location prop to the Switch component whose value is either the background (if it exists) or the new location set by useLocation.

You might be wondering where exactly this background state comes from. Well, we set it in the Link component that navigates to the modal. Since we navigate to our modal from the Card component, let's update it to reflect that.
Card.jsx

import React from "react";
import { Link, useLocation } from "react-router-dom";

const Card = () => {
  const location = useLocation();
  return (
    <div className="w-56 pb-2 mt-8 mx-4 bg-white rounded-md border border-gray-200 overflow-hidden shadow-lg">
      <Link
        to={{
          pathname: "/contact/andrew-garfield",
          state: { background: location }
        }}
      >
        <div className="flex flex-col items-center py-4 px-2 bg-gray-300">
          <span className="w-10 h-10 rounded-full overflow-hidden inline-block">
            <img
              src="https://uifaces.co/our-content/donated/gPZwCbdS.jpg"
              alt=""
            />
          </span>
          <h1 className="text-lg font-medium text-gray-600 mt-2">
            Andrew Garfield
          </h1>
          <p className="text-sm text-gray-600">Project Manager</p>
        </div>
      </Link>
...
Enter fullscreen mode Exit fullscreen mode

I'm showing only the part of the Card component that changed. We now pass an object to the Link component which contains two fields; pathname and state. pathname is the page we're navigating to and state is an object we can pass user defined variables to. So we set the background here which is just the current location we get from useLocation. This is how we tell react-router to use the current location instead of updating to a new location object.

I believe you get the concept now, whenever we want to navigate to a modal, we set the background state to tell react-router that we do not want to leave the current page but just display the modal as an overlay. This gives us the ability to treat the modal as a normal page and use features like history.goBack. I left the Modal component for last so you can see this in action.
Modal.jsx

import React from "react";
import { useHistory } from "react-router-dom";
import Card from "./Card";

const Modal = () => {
  const history = useHistory();

  const closeModal = e => {
    e.stopPropagation();
    history.goBack();
  };

  React.useEffect(() => {
    document.body.classList.add("overflow-hidden");

    return () => {
      document.body.classList.remove("overflow-hidden");
    };
  }, []);

  return (
    <div className="absolute inset-0 bg-black bg-opacity-75 w-full h-screen z-10 flex items-center justify-center">
      <span
        className="inline-block absolute top-0 right-0 mr-4 mt-4 cursor-pointer"
        onClick={closeModal}
      >
        <svg
          class="w-6 h-6 text-white"
          fill="currentColor"
          viewBox="0 0 20 20"
          xmlns="http://www.w3.org/2000/svg"
        >
          <path
            fill-rule="evenodd"
            d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
            clip-rule="evenodd"
          />
        </svg>
      </span>
      <Card />
    </div>
  );
};

export default Modal;
Enter fullscreen mode Exit fullscreen mode

Here, we get the history object from the useHistory hook. We can then call history.goBack() when we want to close the modal to navigate to the previous page, only that the previous page is in-fact the current page.

pageCeption
mind blown meme
Haha, I'm sorry.

Alright, that is the end of this post. Hopefully, it's not too long and you've learned something new. Here's a link to the demo and a github repo

Top comments (9)

Collapse
 
wintercounter profile image
Victor Vincent

How would you handle the case when there is no history yet and the users arrives directly to your modal URL? Because there is no background set in that case, the Switch will use the current location object, generating the wrong content.

Collapse
 
jeremycolton profile image
JeremyColton • Edited

Hi, great article. One problem.

You wrote 'whenever we want to navigate to a modal, we set the background state to tell react-router that we do not want to leave the current page but JUST display the modal as an overlay'.

This implies when a contact is selected, JUST the modal is displayed. BUT, the parent /contacts path is also rendered AGAIN. This is wasteful since the parent hasn't changed. How can we stop the parent from being re-rendered?

I tried memo-izing the parent 'Contacts' component but it still gets re-rendered.

Many thanks.

Collapse
 
ausmurp profile image
Austin Murphy

I don't think you can avoid re rendering parent but you can memoize the components on parent as you attempted. It should work fine. You can:

const ContactsMemo = useMemo(() => <Contacts />, []);

<Parent>
  {ContactsMemo}
<Parent>

Enter fullscreen mode Exit fullscreen mode
Collapse
 
messenja profile image
Dmitry

It doesnt work, if i navigate to route contact/andrew-garfield directly in browser (say, i come from another resourse)

Collapse
 
ausmurp profile image
Austin Murphy

You would need to subscribe to the history listener to make this work- something I recently did in a project.

That being said, there will also be issues with hostory.goBack() to close the modal, if it wasn't opened from the parent page. I ended up pushing a new route to the parent page to close the model - history listener listens for a MODAL_TOKEN unique string (id of modal) on the page as a hash (page/route#the-modal). If there and modal is closed, open. If not there and modal is open, close.

I plan on posting about an advanced router modal soon. Hope this helps.

Collapse
 
akaguny profile image
Alexey

i'd like to navigate for all of the application =)
sometimes in application some forms access only in modals =( but if i have route i can save link =)
maybe in this cases i should take feature request, but we know how long minor/trivial tasks live in backlog =)

Collapse
 
unorthodev profile image
Mayowa Ojo

Hi, thanks for your comment. I assume you mean you want to be able to navigate throughout the application while also having routable modals. With this setup, you can. The modal doesn't affect your normal navigation flow. That's why the modal is only rendered when there's a background state. Also you can have a modal wrapper component and render different components inside the wrapper if you want multiple modals.

Collapse
 
akaguny profile image
Alexey

Another one: what do you think about statefull forms in modal and close modal on submit? I'm really do not think about it such as brilliant idea, but maybe it can be usefull. In example in one of my previous project we have isolated forms that can be opened on fullscreen or in modal. And we was injected dialog controller to form when open in modal, and close modal when form end to work.

Collapse
 
sahildarock profile image
Sahil Srivastava

Great article. Thanks!