Creating a reusable pop-up modal in React from scratch

mangelosanto

Matt Angelosanto

Posted on August 15, 2023

Creating a reusable pop-up modal in React from scratch

Written by Rahul Chhodde✏️

A pop-up modal is a crucial UI element you can use to engage with users when you need to collect input or prompt them to take action. Advancements in frontend web development have made it easier than ever to incorporate modal dialogs into your apps.

In this article, we will focus on utilizing the native HTML5 <dialog> element to construct a reusable pop-up modal component in React. Starting from scratch will allow us to explore the process in detail, including:

You can check out the complete code for our React pop-up modal in this GitHub repository.

What is a modal dialog?

A modal dialog is a UI element that temporarily blocks the user from interacting with the rest of the application until a specific task is completed or canceled. It overlays the main interface and demands the user’s attention.

A typical example of a modal is an email subscription box frequently found on blog websites. Until the user responds by subscribing or dismissing the modal, they cannot interact with the underlying content in the main interface.

Other examples include login or signup dialogs, file upload boxes, file deletion confirmation prompts, and more.

Modals are useful for presenting critical alerts or obtaining important user input. However, they should be used sparingly to avoid disrupting the user experience unnecessarily.

What is a non-modal dialog?

Non-modal dialogs, in contrast to modal dialogs, allow users to interact with the application while the dialog is open. They are less intrusive and do not demand immediate attention.

Some examples of non-modal dialogs are site preference panels, help dialogs, cookie consent dialogs, context menus — the list goes on.

This article is primarily focused on modal dialogs. Instead of expanding further on non-modals, we will maintain our emphasis on creating and discussing modal dialogs.

Understanding the native HTML <dialog> element

Before the native HTML <dialog> element was introduced, developers had to rely solely on JavaScript to add the required HTML to the document to obtain the modal functionality.

However, the native HTML <dialog> element is now widely supported on modern browsers. Thanks to the JavaScript API specifically designed for the <dialog> element, modal dialogs have become more semantically coherent and easier to handle.

This also means that you no longer require a third-party library to construct your own pop-up modals.

How to construct a modal using the HTMLDialogElement API

The markup required to structure a native <dialog> element is quite straightforward. Let's explore the essential markup for constructing a modal using the <dialog> element:

<button id="openModal">Open the modal</button>

<dialog id="modal" class="modal">
  <button id="closeModal" class="modal-close-btn">Close</button>
  <p>...</p>
  <!-- Add more elements as needed -->
</dialog>
Enter fullscreen mode Exit fullscreen mode

Note that the modal component can be set to open by default by including the open attribute within the <dialog> element in the markup:

<dialog open>
  ...
</dialog>
Enter fullscreen mode Exit fullscreen mode

We can now utilize the JavaScript HTMLDialogElement API to control the visibility of the modal component that we previously defined. It's a straightforward process that involves obtaining references to the modal itself along with the buttons responsible for opening and closing it.

By utilizing the showModal and close methods provided by the HTMLDialogElement API, we can easily establish the necessary connections:

const dialog = document.getElementById('myDialog');
const openDialogButton = document.getElementById('openDialog');
const closeDialogButton = document.getElementById('closeDialog');

openDialogButton.addEventListener('click', () => {
  dialog.showModal();
});

closeDialogButton.addEventListener('click', () => {
  dialog.close();
});
Enter fullscreen mode Exit fullscreen mode

Note that if we use dialog.show() instead of dialog.showModal(), our <dialog> element will behave like a non-modal element. Take a look at the following implementation. It may be simple and lacking in style, but it is fully functional. Moreover, it is much easier to integrate and provides greater semantic value compared to a comprehensive modal solution built entirely with JavaScript:

See the Pen Simple modal example by Rahul C (@_rahul) on CodePen.

Styling a modal powered by the HTML <dialog> element

A modal interface powered by the HTML <dialog> element is easy to style and has a special pseudo-class that makes modal elements simple to select and style. I’ll keep the styling part simple for this tutorial and focus more on the basics before delving into the React implementation.

The :modal pseudo-class

The :modal CSS pseudo-class was specifically designed for UI elements with modal-like properties. It enables easy selection of a dialog displayed as a modal and the application of appropriate styles to it:

dialog {
  /* Styles for dialogs that carry both modal and non-modal behaviors */
}

dialog:modal {
  /* Styles for dialogs that carry modal behavior */
}

dialog:not(:modal) {
  /* Styles for dialogs that carry non-modal behavior */
}
Enter fullscreen mode Exit fullscreen mode

The choice between these approaches — selecting an element directly to set defaults, selecting its states to apply state-specific styles, or using CSS classes to style the components — is entirely subjective.

Each method offers different advantages, so the most suitable approach for styling will depend on the developer’s preference and the project’s operating procedure. I’ll go the CSS classes route to style our modal.

Let's enhance it by incorporating rounded corners, spacing, a drop shadow, and some layout properties. You can add or customize these properties according to your specific needs:

.modal {
  position: relative;
  max-width: 20rem;
  padding: 2rem;
  border: 0;
  border-radius: 0.5rem;
  box-shadow: 0 0 0.5rem 0.25rem hsl(0 0% 0% / 10%);
}
Enter fullscreen mode Exit fullscreen mode

Additionally, we'll position the Close button in the top right corner so that it doesn’t interfere with the modal content. Further, we'll set some default styles for the buttons and input fields used in our application:

.modal-close-btn {
  font-size: .75em;
  position: absolute;
  top: .25em;
  right: .25em;
}

input[type="text"],
input[type="email"],
input[type="password"],
button {
  padding: 0.5em;
  font: inherit;
  line-height: 1;
}

button {
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

The ::backdrop pseudo-element

When using traditional modal components, a backdrop area typically appears when the modal is displayed. This backdrop acts as a click trap, preventing interaction with elements in the background and focusing solely on the modal component.

To emulate this functionality, the native <dialog> element introduces the CSS ::backdrop pseudo-element. Here's an example illustrating its usage:

.modal::backdrop {
  background: hsl(0 0% 0% / 50%);
}
Enter fullscreen mode Exit fullscreen mode

The user agent style sheet will automatically apply default styles to the backdrop pseudo-element of dialog elements with a fixed position, spanning the full height and width of the viewport.

The backdrop feature will not function for non-modal dialog elements, as this type of element allows users to interact with the underlying content while the dialog is open.

The following example showcases an example implementation of all the aforementioned styling. Click the "Open the modal" button to observe the functionality in action:

See the Pen Custom styled modal demo by Rahul C (@_rahul) on CodePen.

Notice how the previously mentioned backdrop area works. When the modal is open, you’re not able to click on anything in the background until you click the Close button.

Creating a pop-up modal in React using the <dialog> element

Now that we understand the basic HTML structure and styles of our pop-up modal component, let's transfer this knowledge to React by creating a new React project.

In this example, I'll be using React with TypeScript, so the code provided will be TypeScript-specific. However, I also have a JavaScript-based demo of the component we are about to build that you can reference if you are using React with JavaScript instead.

Once the React project is set up, let's create a directory named components. Inside this directory, create a sub-directory called Modal to manage all of our Modal dialog component files. Now, let’s create a file inside the Modal directory called Modal.tsx:

import React from "react";

const Modal: React.FC = () => {
  const modalRef = useRef<HTMLDialogElement | null>(null);

  return (
    <dialog ref={modalRef} className="modal">
      {children}
    </dialog>
  );
}

export default Modal;
Enter fullscreen mode Exit fullscreen mode

In the above code snippet, we define the Modal component using the React functional component syntax. We use the useRef Hook to create a reference to the HTML <dialog> element that we could use later on in the useEffect Hooks.

To make this component work, we need to consider the following points to determine the props we will need:

  • Check open/closed state: We need to keep track of the state of the dialog component — whether it is open or closed. We can use another Boolean prop for this purpose
  • Decide on a close button: We need to decide whether or not the dialog component should include a close button. This can also be controlled through a Boolean prop
  • Define closing behavior: We need to specify the desired behavior when the dialog is closed. This includes determining what actions or events should be triggered upon closing, which can be accomplished with a callback function as a prop
  • Handle children appropriately: We need to enable this component to accept other HTML nodes as children. This can be achieved by utilizing the special props.children prop provided by React

The above points contribute to shaping the type structure of our props, which we will construct using the TypeScript interface as illustrated below:

interface ModalProps {
  isOpen: boolean;
  hasCloseBtn?: boolean;
  onClose?: () => void;
  children: React.ReactNode;
};
Enter fullscreen mode Exit fullscreen mode

After planning the props for the Modal component, it is ideal to define a state variable to manage its opening and closing states:

const Modal: React.FC<MOdalProps> = ({ isOpen, hasCloseBtn, onClose, children }) => {
  const [isModalOpen, setModalOpen] = useState(isOpen);
  const modalRef = useRef<HTMLDialogElement | null>(null);

  return (
    <dialog ref={modalRef} className="modal">
      {children}
    </dialog>
  );
};
Enter fullscreen mode Exit fullscreen mode

With the basic structure of our Modal component in place, we can now proceed to implement the functionality for opening the modal.

Opening the Modal

The useEffect Hook is ideal for keeping things in sync because it enables performing side effects, such as updating states or interacting with APIs, in response to changes in specific dependencies.

In the case of opening our modal, we can use a useEffect Hook that gets triggered whenever the isOpen prop changes. This ensures that the isModalOpen state stays in sync with the isOpen prop, allowing the component to respond accurately to external changes while maintaining consistency between the two:

useEffect(() => {
  setModalOpen(isOpen);
}, [isOpen]);
Enter fullscreen mode Exit fullscreen mode

Note that we are not going to take advantage of conditionally rendering elements in the DOM based on React state variables. Instead, we will use the HTMLDialogElement API to manage the visibility of our Modal dialog.

We used this same approach in the plain HTML and JavaScript example we explored above. This approach will also allow us to make the most of the built-in accessibility features provided by the native HTML <dialog> element.

To implement the HTML <dialog> modal with React, we will utilize the isModalOpen state variable in conjunction with another useEffect Hook. This Hook will control the modal’s visibility by calling Dialog.showModal() when isModalOpen is true, effectively displaying the modal.

Conversely, when isModalOpen is false, it will invoke Dialog.close() to hide the modal. This way, the modal’s display state will always be in sync with the value of the isModalOpen state variable. See the code below:

useEffect(() => {
  const modalElement = modalRef.current;
  if (modalElement) {
    if (isModalOpen) {
      modalElement.showModal();
    } else {
      modalElement.close();
    }
  }
}, [isModalOpen]);
Enter fullscreen mode Exit fullscreen mode

Closing the Modal

We can create a utility function that incorporates the optional onClose callback and set isModalOpen to false. This function can be used later to easily close the Modal dialog in different scenarios:

const handleCloseModal = () => {
  if (onClose) {
    onClose();
  }
  setModalOpen(false);
};
Enter fullscreen mode Exit fullscreen mode

If you observe closely, the ability to close the modal by pressing the escape key is an inherent feature of the HTML5 <dialog> element.

However, since we are managing the states of our Modal component using the useState Hook, we need to update it accordingly when the escape key is pressed to ensure the proper functioning of the Modal dialog.

To achieve this, we can simply listen for a KeyDown event and call the handleCloseModal function, which we declared earlier, whenever the event corresponds to the escape key:

const handleKeyDown = (event: React.KeyboardEvent<HTMLDialogElement>) => {
  if (event.key === "Escape") {
    handleCloseModal();
  }
};
Enter fullscreen mode Exit fullscreen mode

This approach ensures that the modal is closed appropriately when the user presses the escape key and prevents any conflicts between the HTMLDialogElement API and React states.

Piecing it all together

In the final steps, we will utilize the optional hasCloseBtn prop to include a close button inside the Modal component. This button will be linked to handleCloseModal action, which is designed to close the modal as expected.

Additionally, we will implement the handleKeyDown function and associate it with the onKeyDown event handler for the main HTML5 <dialog> element that will be returned by the Modal component.

See the code below:

return (
  <dialog ref={modalRef} onKeyDown={handleKeyDown}>
    {hasCloseBtn && (
      <button className="modal-close-btn" onClick={handleCloseModal}>
        Close
      </button>
    )}
    {children}
  </dialog>
);
Enter fullscreen mode Exit fullscreen mode

With these updates, our React Modal component is now fully functional and complete, making use of the powerful HTML5 <dialog> element and its JavaScript API.

Using our React Modal component

Now, let's put the modal dialog component to use and observe its functionality.

For this purpose, we’ll consider one of the commonly seen modal UI elements on the web: the typical newsletter subscription modal dialog. This modal will include some form fields and invite the visitor to sign up for a newsletter subscription.

The purpose of developing this specific component is to showcase the versatility of the modal pattern for creating various types of modals.

Additionally, we will demonstrate how to gather and manage data in the frontend using this method. Moreover, the same data can be seamlessly transferred to either the frontend or the API as required.

Setting up the NewsletterModal component

The plan is to create an additional component responsible for managing the form and its data within our newsletter modal dialog. To achieve this, let’s create a new subdirectory named NewsletterModal under the components directory.

Within the NewsletterModal directory, create a new file called NewsletterModal.tsx, which will serve as our NewsletterModal component. Optionally, you can also add a NewsletterModal.css file to style the component according to your requirements.

Let’s begin by importing some essential dependencies, including our Modal component that we finished in the previous section:

import React, { useState, useEffect, useRef } from 'react';
import './NewsletterModal.css';
import Modal from '../Modal/Modal';
Enter fullscreen mode Exit fullscreen mode

Defining types and props

Our newsletter form will comprise two input fields — one to collect the user's email and the other to allow users to choose their newsletter frequency preferences. We’ll include monthly, weekly, or daily options in the latter field.

To achieve this, we will once again utilize TypeScript interfaces. We'll also export this interface to reuse it in the main App component:

export interface NewsletterModalData {
  email: string;
  frequency: string;
}
Enter fullscreen mode Exit fullscreen mode

Based on the provided type definition, we can now set the default or initial data that our newsletter form should hold. We'll use an object to manage the data for the email and frequency fields:

const initialNewsletterModalData: NewsletterModalData = {
  email: '',
  frequency: 'weekly',
};
Enter fullscreen mode Exit fullscreen mode

Next, we'll define the props that our NewsletterModal component will receive:

interface NewsletterModalProps {
  isOpen: boolean;
  onSubmit: (data: NewsletterModalData) => void;
  onClose: () => void;
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the NewsletterModal component expects three props:

  • isOpen — A Boolean indicating whether the modal is open or not
  • onSubmit — A function that will be called when the form is submitted. It takes a property of the NewsletterModalData type as an argument
  • onClose — A function that will be called when the user closes the modal

Two of these props, namely isOpen and onClose, will further be used as prop values for the Modal component.

Defining the NewsletterModal component

Now, let's define the actual NewsletterModal component. It's a functional component that takes in the props defined in the NewsletterModalProps interface. We use object destructuring to extract these props:

const NewsletterModal: React.FC<NewsletterModalProps> = ({
  onSubmit,
  isOpen,
  onClose,
}) => {
  // Component implementation goes here...
};
Enter fullscreen mode Exit fullscreen mode

Managing states and references

Next, we use the useRef Hook to create a reference to the input element for the email field. This reference will be used later to focus on the email input when the modal is opened.

We also use the useState Hook to create a state variable to manage the form data, initializing it with initialNewsletterModalData.

See the code below:

const focusInputRef = useRef<HTMLInputElement | null>(null);
const [formState, setFormState] = useState<NewsletterModalData>(
  initialNewsletterModalData
);
Enter fullscreen mode Exit fullscreen mode

To handle side effects when the value of isOpen changes, we utilize the useEffect Hook. If isOpen is true and the focusInputRef is available, not null, we use setTimeout to ensure that the focus on the email input element happens asynchronously:

useEffect(() => {
  if (isOpen && focusInputRef.current) {
    setTimeout(() => {
      focusInputRef.current!.focus();
    }, 0);
  }
}, [isOpen]);
Enter fullscreen mode Exit fullscreen mode

This allows the modal to be fully rendered before focusing on the input.

Handling data input

The function handleInputChange is responsible for handling changes in the two form input fields — the user’s email address and newsletter frequency preferences. This function is triggered by the onChange event of the email input and frequency select elements:

const handleInputChange = (
  event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
): void => {
  const { name, value } = event.target;
  setFormState((prevFormData) => ({
    ...prevFormData,
    [name]: value,
  }));
};
Enter fullscreen mode Exit fullscreen mode

When called, the function extracts the name and value from the event's target — in other words, the form element that triggered the change. It then uses the setFormState state variable to update the form state.

Additionally, the handleInputChange function uses the callback form of setFormState to correctly update the state. This preserves the previous form data using the spread operator — ...prevFormData — and updates only the changed field:

Handling form submission

The function handleSubmit is called when the form is submitted. It is triggered by the onSubmit event of the form:

const handleSubmit = (event: React.FormEvent): void => {
  event.preventDefault();
  onSubmit(formState);
  setFormState(initialNewsletterModalData);
};
Enter fullscreen mode Exit fullscreen mode

This function prevents the default form submission behavior using event.preventDefault() to avoid a page reload. Then, it calls the onSubmit function from props, passing the current formState as an argument to submit the form data to the parent component.

After submission, it resets the formState to initialNewsletterModalData, effectively clearing the form inputs.

Consuming the Modal component

In the JSX block, we return the Modal component, which will be rendered with the modal’s content.

We use our custom Modal component and pass it three props — hasCloseBtn, isOpen, and onClose. The form elements — inputs, labels, and submit button — will be rendered within the Modal component:

return (
  <Modal
    hasCloseButton={true}
    isOpen={isOpen}
    onClose={onClose}
  >
    {/* Form JSX goes here... */}
  </Modal>
);
Enter fullscreen mode Exit fullscreen mode

Inside the Modal component, we render a form element containing two sections with labels and form elements corresponding to the input field and select dropdown. The input field is for the user's email, and the select dropdown allows the user to choose the newsletter frequency.

We bind these elements with the onChange event handler to update the formState when the user interacts with the form. The form element has an onSubmit event that triggers the handleSubmit function when the user submits the form:

<form onSubmit={handleSubmit}>
  <div className="form-row">
    <label htmlFor="email">Email</label>
    <input
      ref={focusInputRef}
      type="email"
      id="email"
      name="email"
      value={formState.email}
      onChange={handleInputChange}
      required
    />
  </div>
  <div className="form-row">
    <label htmlFor="digestType">Digest Type</label>
    <select
      id="digestType"
      name="digestType"
      value={formState.digestType}
      onChange={handleInputChange}
      required
    >
      <option value="daily">Daily</option>
      <option value="weekly">Weekly</option>
      <option value="monthly">Monthly</option>
    </select>
  </div>
  <div className="form-row">
    <button type="submit">Submit</button>
  </div>
</form>
Enter fullscreen mode Exit fullscreen mode

And this concludes our NewsletterModal component. We can now export it as a default module and move on to the next section, where we will use it and finally see our Modal component in action.

Implementing the NewsletterModal

In our App.tsx file — or any parent component of your choice — let's begin by importing the necessary dependencies such as React, useState, NewsletterModal, and NewsletterModalData. If desired, we can also use the App.css or the related component stylesheet to style this parent component:

import React, { useState } from 'react';
import NewsletterModal, { NewsletterModalData } from './components/NewsletterModal/NewsletterModal';
import './App.css';
Enter fullscreen mode Exit fullscreen mode

As discussed earlier, NewsletterModalData is an interface that defines the shape of the data to be passed between components to support the data within our NewsletterModal component.

Within the App component, we utilize the useState Hook to establish two state variables:

  • isNewsletterModalOpen — A boolean state variable that tracks whether the newsletter modal is open or not. It is initialized as false, meaning the modal is initially closed
  • newsletterFormData — A state variable that holds the form data submitted through the NewsletterModal. It is initialized as null since no data is available initially

Here’s how the code should look:

const App: React.FC = () => {
  const [isNewsletterModalOpen, setNewsletterModalOpen] = useState<boolean>(false);
  const [newsletterFormData, setNewsletterFormData] = useState<NewsletterModalData | null>(null);
  // Rest of the component implementation goes here...
};
Enter fullscreen mode Exit fullscreen mode

To handle the modal states, we define two functions — handleOpenNewsletterModal and handleCloseNewsletterModal. These functions are used to control the state of the isNewsletterModalOpen variable.

When handleOpenNewsletterModal is called, it sets isNewsletterModalOpen to true, opening the newsletter modal. When handleCloseNewsletterModal is called, it sets isNewsletterModalOpen to false, closing the newsletter modal.

See the code below:

const handleOpenNewsletterModal = () => {
  setNewsletterModalOpen(true);
};

const handleCloseNewsletterModal = () => {
  setNewsletterModalOpen(false);
};
Enter fullscreen mode Exit fullscreen mode

The handleSubmit function is called when the user submits the form inside the NewsletterModal. It takes the form data from the NewsletterModalData interface as an argument.

When called, the handleSubmit function sets the newsletterFormData state variable to the submitted data. After setting the data, it calls handleCloseNewsletterModal to close the modal:

e>const handleFormSubmit = (data: NewsletterModalData): void => {
  setNewsletterFormData(data);
  handleCloseNewsletterModal();
};
Enter fullscreen mode Exit fullscreen mode

Finally, we return the JSX that will be displayed as the UI for the App component.

In the JSX, we have a div containing a button. When clicked, this button triggers the handleOpenNewsletterModal function, thereby opening the newsletter modal.

We check if newsletterFormData is not null and if its email property is truthy. If both conditions are met, we render a message using the data from the newsletterFormData.

Then, we render the NewsletterModal component, passing the necessary props — isOpen, onSubmit, and onClose. These props are set as follows:

  • isOpen — set to the value of isNewsletterModalOpen to determine whether the modal should be displayed or not
  • onSubmit — set to the handleSubmit function to handle form submissions
  • onClose — set to the handleCloseNewsletterModal function to close the modal when requested

See the code below:

return (
  <>
    <div style={{ display: "flex", gap: "1em" }}>
      <button onClick={handleOpenNewsletterModal}>Open the Newsletter Modal</button>
    </div>

    {newsletterFormData && newsletterFormData.email && (
      <div className="msg-box msg-box--success">
        <b>{newsletterFormData.email}</b> requested a <b>{newsletterFormData.frequency}</b> newsletter subscription.
      </div>
    )}

    <NewsletterModal
      isOpen={isNewsletterModalOpen}
      onSubmit={handleFormSubmit}
      onClose={handleCloseNewsletterModal}
    />
  </>
);
Enter fullscreen mode Exit fullscreen mode

That’s it! We now have our App component up and running, showing a button to open a functional newsletter modal. When the user submits the form with the appropriate information, that data is displayed on the main app page, and the modal is closed.

Check out the below given CodePen demo showcasing the implementation of all the code snippets mentioned earlier:

See the Pen Newsletter form modal demo by Rahul C (@_rahul) on CodePen.

For a well-organized and comprehensive version of this project, you can access the complete code on GitHub. Please note that this implementation is written in TypeScript, but it can be adapted to JavaScript by removing the type annotations as I did in this StackBlitz demo here.

Conclusion

Nowadays, methods for creating modal dialogs no longer rely on third-party libraries. Instead, we can utilize the widely supported native <dialog> element to enhance our UI modal components.

The article provided a detailed explanation of creating such a modal component in React, which can be further extended and customized to suit the specific requirements of your project.

If you have any questions, feel free to let me know.


Get set up with LogRocket's modern React error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on August 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related