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>
);
}
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")
);
We put our
Router
in theindex.js
file instead ofApp.js
because we would later need to access some hooks inApp.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;
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;
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 toCard
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;
The
Card
component contains aLink
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>
);
}
We've introduced two new things;
location
andbackground
.location
is an object that containsurl
information which we get from theuseLocation
hook. It updates to the newurl
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 theRoute
component for the modal in theSwitch
component, rather we put it outside and conditionally render depending on the value ofbackground
.
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>
...
I'm showing only the part of the
Card
component that changed. We now pass an object to theLink
component which contains two fields;pathname
andstate
.pathname
is the page we're navigating to andstate
is an object we can pass user defined variables to. So we set thebackground
here which is just the current location we get fromuseLocation
. 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;
Here, we get the history object from the
useHistory
hook. We can then callhistory.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.
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)
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, theSwitch
will use the current location object, generating the wrong content.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.
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:
It doesnt work, if i navigate to route contact/andrew-garfield directly in browser (say, i come from another resourse)
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.
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 =)
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.
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.
Great article. Thanks!