Dec 18, 2022

Build a React-Router Dialog

React router dialog

What is a Dialog?

According to Material Design, Dialogs (also known as Modals), can require an action, communicate information, or help users accomplish a task.

Dialogs allow us, to focus on a content without losing the background contextual information associated with it.

Instagram Example

Imagine you're on Instagram scrolling a list of posts and you click on a post to see more details about it. Traditionally this could be implemented by just navigating to another page, but this - at times - does not feel very user-friendly.

What can be done instead, is to show a Dialog with the details of this post, without leaving the page preserving the list of posts in the background. Then, the user can just close the post and move on with the list, clicking on another post and so on.

Look at what Instagram does with their posts and how user-friendly it feels:

instagram-dialog-example

A Naive Attempt

We could assume that we could implement the functionality above with writing something like this:

const App = () => {
  const [selectedPost, setSelectedPost] = useState(null);

  return <div>
    <ListOfPosts>
      {posts.map(post => <Post post={post} onClick={() => setSelectedPost(post)}/>)}
    </ListOfPosts>
    <PostDetailsDialog selectedPost={selectedPost}>
  <div/>
}

Looks like pretty straight-forward right?

Or... maybe not?

The above code could definitely work in general, but we're missing one very important thing.

What about the page url? With an implementation like the above, the url of the page does not change each time a Post is shown.

And from a UI/UX perspective, it's always for the best if the page-url follows the user journey of our app.

Furthermore, by changing the page url when the PostDetailsDialog is shown and by keeping a unique url for each post, we also allow the user to easily share the post by just sharing the URL that is present at that moment on their browser!

Now, take a look at how Instagram behaves, when we share the url of a post: https://www.instagram.com/p/CfeYvQOD1z-/

The post shows up in a new page without being inside a Dialog and without showing the list of posts in the background. It just focuses on that shared post.

Requirements

These are the requirements that need to be met in order to achieve the aforementioned functionality:

  1. Show a Dialog for each post clicked
  2. Page url must change and must be unique for each post
  3. When a user visits a Post from a shared URL, the post must be shown normally in a new page (without being inside a Dialog)

We can use React Router combined with a Dialog component in order to satisfy the requirements above.

Code & Implementation

You can find the full code here: https://codesandbox.io/s/react-router-modal-gallery-qs44n1

What we will achieve (notice how the URL changes)

peek1


And when we visit the image from a shared url the post opens in an new page:

peek2

Coding time

The key thing here, is that we are going to utilize the location prop of the Routes component provided by react-router-dom.

Basically, by using the location prop we can "override" the actual location of where our app is at that moment. The backgroundLocation (you can name the variable whatever you like) comes from the location state of our app.

We then check if the backgroundLocation is set, and if it is, it means that we must show a Dialog and preserve the background location, thus we apply the location prop at the Routes component.

Otherwise, if the backgroundLocation is not set, then the location prop just takes the default value of location and our app follows the "normal" flow - that means that our Post will be rendered as is inside the page (and not inside a Dialog).

// App.js
import { Box, Container, Divider, Typography } from "@mui/material";
import { Outlet, Route, Routes, useLocation } from "react-router-dom";
import About from "./About";
import ImageList from "./ImageList";
import ImageView from "./ImageView";
import ImageModal from "./ImageModal";
import Home from "./Home";

const Layout = () => (
  <Container maxWidth="md">
    <Box mb={2}>
      <Typography variant="h4">Application Demo</Typography>
      <Divider />
    </Box>
    <Outlet />
  </Container>
);

const App = () => {
  const location = useLocation();
  const { state } = location;

  return (
    <div>
      <Routes location={state?.backgroundLocation || location}>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/images" element={<ImageList />} />
          <Route path="/images/:id" element={<ImageView />} />
        </Route>
      </Routes>

      {/* Show the modal when a `backgroundLocation` is set */}
      {state?.backgroundLocation && (
        <Routes>
          <Route path="/images/:id" element={<ImageModal />} />
        </Routes>
      )}
    </div>
  );
};

export default App;

Take a look at how we can set the state alongside the url in a Link component, when the user clicks on a image. It's like saying to our app: "Hey, the user clicked on an image, so we must show the image inside a Dialog and preserve the background location".

// ImageList.js
import { Box, Grid, Stack, Typography } from "@mui/material";
import React, { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { IMAGES } from "./images";

const ImageBox = ({ image, location }) => {
  const [hovered, setHovered] = useState(false);
  const { id, src } = image;

  return (
    <Box width="100%" position="relative">
      <Link to={`/images/${id}`} state={{ backgroundLocation: location }}>
        <Box
          onMouseEnter={() => setHovered(true)}
          component="img"
          src={src}
          alt="post"
          width="100%"
          height="250px"
        />
        {hovered && (
          <Box
            onMouseLeave={() => setHovered(false)}
            position="absolute"
            top={0}
            left={0}
            bottom={0}
            right={0}
            width="100%"
            height="250px"
            bgcolor="rgba(0, 0, 0, 0.3)"
            // sx={{ opacity: 0.6 }}
          />
        )}
      </Link>
    </Box>
  );
};

const ImageList = () => {
  let location = useLocation();

  return (
    <Grid container spacing={2} alignItems="center" justifyContent="center">
      {IMAGES.map(({ title, src, id }, index) => (
        <Grid key={id} item sm={4}>
          <ImageBox image={{ title, src, id }} location={location} />
        </Grid>
      ))}
    </Grid>
  );
};

export default ImageList;
// ImageModal.js
import { useNavigate, useParams } from "react-router-dom";
import ImageView from "./ImageView";
import { getImage } from "./images";
import { Button, Dialog, DialogActions, DialogContent } from "@mui/material";

const ImageModal = () => {
  let navigate = useNavigate();
  let { id } = useParams();
  let image = getImage(id);

  if (!image) return null;

  const handleClose = () => {
    navigate(-1);
  };

  return (
    <Dialog open onClose={handleClose}>
      <DialogContent>
        <ImageView />
      </DialogContent>
      <DialogActions>
        <Button onClick={handleClose}>Close</Button>
      </DialogActions>
    </Dialog>
  );
};

export default ImageModal;
// ImageView.js
import { ArrowBackIos } from "@mui/icons-material";
import { Button, Link, Stack, Typography, Box } from "@mui/material";
import React from "react";
import { useLocation, useParams, Link as RouterLink } from "react-router-dom";
import { getImage } from "./images";

const ImageView = () => {
  const { state } = useLocation();
  const { id } = useParams();
  const image = getImage(id);
  const { src, title } = image;

  const isInsideModal = state?.backgroundLocation;

  return (
    <div>
      {!isInsideModal && (
        <Link component={RouterLink} to="/images" underline="none">
          <Button startIcon={<ArrowBackIos />} variant="text">
            Back to Images
          </Button>
        </Link>
      )}
      <Box display="flex" justifyContent="center">
        <Stack direction="column" spacing={1}>
          <Typography variant="h5" fontWeight={600}>
            {title}
          </Typography>

          <Box component="img" sx={{ width: "400px" }} src={src} alt="dog" />
        </Stack>
      </Box>
    </div>
  );
};

export default ImageView;

The End

I hope you found the article informative.

See you soon! 🙂

Loading Comments...