How to create Single Modal in Gatsby using Reactstrap, React Portals, React Context and React Custom Hooks

|11 min read|
Teklog
Teklog


Introduction

Gatsby is a free and open-source static site generator based on React. In this tutorial, we will use the default starter kit with cosmetic modifications.

Reactstrap provides React components based on Bootstrap 4 library. It comes with a powerful Modal component and his three supporting sub-components:

Armed with those, you can get a working Model almost in no time. Reactstrap documentation is full of examples, so feel free to explore its Modals part on your own.

Out of the box solution is pretty comprehensive, but it forces you to create many Modals. You must control them at least with one show/hide variable and with one toggle function. They are following drawbacks with this approach:

  • The more Modals you have, the more variables and toggle functions you need to take care of. You could do it via Higher Order Component, React Context or even via Redux, but it may become troublesome.
  • If you wanted to reuse the same Modal between sibling components, you may end up replicating way too much of your JSX code. Also, you would need to introduce some local state in your stateless component.

The good thing about Reactstrap Modal is that it uses React Portals to render his content into a separate DOM node. So, even if you have dozens of Modals in your JSX tree, only one will be actually rendered. And gladly, it will also sit outside your main DOM tree, where usually your React/Gatsby application is mounted into.

The less neat part is that this Modal gets added and removed from DOM every time you open or close it. And you cannot even control where it renders into...

Wouldn't it be ideal if you could pre-render the Modal at the bottom of our HTML, only swapping his content? It is possible, but requires a bit of hack here and there.

TL;DR

If you can't wait to see the code, I've created a Github repository for you. Otherwise, let's start off our tutorial.

Proposal / Plan

So to get this Single Modal working, we must follow a 7-step plan:

  1. Instruct Gatsby to create a separate div with id "single-modal"
  2. Extend Reactstrap Modal to render his content inside this "single-modal" element
  3. Wrap every Gatsby page inside Layout component
  4. Use React Context to create SingleModalProvider and useSingleModal custom hook
  5. Wrap your application within SingleModalProvider
  6. Render Extended Modal at the bottom of the Layout with sane defaults
  7. Call the Modal from within the Gatsby page

Assumptions

I assume you are comfortable with Gatsby. I also assume you tinkered around with React Context and React Hooks before.

If not, there are some recommended materials to read, before you proceed.

If you are ready, let's start right off the bet, by moving into the first step.

Instruct Gatsby to create a separate div with id "single-modal"

By default, Gatsby renders each page under <div id="___gatsby"> DOM element.

The only way to add another div is to use the Gatsby Server Rendering APIs. We are going to use onRenderBody method.

Open the gatsby-ssr.js file and put in place following piece of code

const onRenderBody = ({ setPostBodyComponents }) => {
 setPostBodyComponents([<div key="single-modal" id="single-modal" />]);
};

export { onRenderBody };

Extend Reactstrap Modal to render his content inside this "single-modal" element.

Reactstrap Modal offers no prop that can overwrite the DOM element it will render into. Fortunately, we can extend it and tweak a few parts in there.

Create a file src/components/modal.js and put following piece in there

import { Modal } from 'reactstrap';

class SingleModal extends Modal {
  constructor(props) {
    super(props);

    this._element = document.getElementById('single-modal');
    this._element.setAttribute('tabindex', '-1');
    this._element.style.position = 'relative';
    this._element.style.zIndex = this.props.zIndex;
  }

  componentWillUnmount() {
    if (this.props.onExit) {
      this.props.onExit();
    }

    if (this._element) {
      this.manageFocusAfterClose();
      if (this.state.isOpen) {
        this.close();
      }
    }

    this._isMounted = false;
  }
}

export { SingleModal };

In the constructor, we grab the single-modal element and assign it to _element variable. By doing this, we opt-out from the default behavior of Reactstrap Modal. This means that Reactstrap will render his content inside our "single-modal" div.

Reactstrap Modal comes with a handy unmountOnClose prop.  Whenever set to false, it will keep the Modal in the DOM.

The only problem is that Modal's componentWillUnmount lifecycle method ignores unmountOnClose prop completely. And that is the reason to overwrite it by remove the destroy function call.

I agree that going this route feels a bit like a hack. If you update Reactstrap, you must always double-check if everything works. If you have time to roll out your own Modal component, it will be definitely a better solution.

Wrap every Gatsby page inside Layout component

By default, Gatsby Starter Kit gives us Layout component and three pages. It's fine to use Layout at every page you create, but Gatsby comes with a handy wrapPageElement Browser API for that.

Open gatsby-browser.js and paste following code in there

import React from 'react';
import Layout from './src/components/layout';

const wrapPageElement = ({ element, props }) => {
  return <Layout {...props}>{element}</Layout>;
};

export { wrapPageElement };

Having this, you can remove now the Layout dependency from src/pages/index.js, src/pages/404.js and src/pages/page-2.

With this approach, even a dynamic page in Gatsby will have a wrapping Layout component. Pretty neat!

Use React Context to create SingleModalProvider and useSingleModal custom hook

In here, we are going to touch the state management part. We want to allow every Component on every Page to have access to two variables.

  • one for controlling the show/hide of the Modal
  • one for displaying the content of the Modal

We also want to allow to close, toggle and set the content of the modal. And for this we will need to expose some methods.

React Context appears as a perfect solution here. We could use Redux or Mobx, but it doesn't matter. Feel free to experiment with it yourself.

Let's start off by creating a src/contexts/SingleModalContext.js file with the following content.

import React, { useState, useContext } from 'react';

const SingleModalContext = React.createContext();

const useSingleModal = () => {
  const singleModalContext = useContext(SingleModalContext);

  if (!singleModalContext) {
    throw new Error('useSingleModal must be used inside the SingleModalProvider');
  }

  return singleModalContext;
};

const SingleModalProvider = (props) => {
  const [showModal, setShowModal] = useState(false);
  const [modalContent, setModalContent] = useState(null);

  const toggleModal = () => {
    setShowModal(!showModal);
  };

  const closeModal = () => {
    setShowModal(false);
  };

  const setContent = (content) => {
    setModalContent(content());
  };

  const value = {
    show: showModal,
    toggle: toggleModal,
    close: closeModal,
    setContent,
    content: modalContent,
  };

  return <SingleModalContext.Provider value={value} {...props} />;
};

export { useSingleModal, SingleModalProvider };

There are a few notable parts in there. We create the context first.

const SingleModalContext = React.createContext();

Then, we build a Custom Hook called useSingleModal which uses the SingleModalContext.

const useSingleModal = () => {
  const singleModalContext = useContext(SingleModalContext);
  // ...
  return singleModalContext;
}

Then we craft the SingleModalProvider, which will provide two variables and three functions.

const value = {
  show: showModal,
  toggle: toggleModal,
  close: closeModal,
  setContent,
  content: modalContent,
};

return <SingleModalContext.Provider value={value} {...props} />;

The most interesting function is setContent. Its argument must be a Closure, which means it can accept the JSX syntax. This will allow to use ModalBody, ModalHeader and ModalFooter inside your Modal.

Wrap your application within SingleModalProvider

Also in here, Gatsby has us covered. It delivers the wrapRootElement Browser API and wrapRootElement SSR API

Open gatsby-browser.js file and add the following

import { SingleModalProvider } from './src/contexts/SingleModalContext';

const wrapRootElement = ({ element }) => <SingleModalProvider>{element}</SingleModalProvider>;
export { wrapRootElement };

The exact same snippet must go inside gatsby-ssr.js, so please adjust it too.

Render Extended Modal at the bottom of the Layout with sane defaults

At this point, we have a DOM element to render the Modal into. We have an overwritten Reactstrap Modal so it will mount there forever. We have also set up our state management with React Context. Our application uses SingleModalProvider and each Gatsby page makes usage of Layout component.

The only part missing is to create the Single Modal itself. The best place for doing it is at the bottom of the Layout component. So open the src/components/layout.js and adjust it as shown below.

import { SingleModal } from './modal';
import { useSingleModal } from '../contexts/SingleModalContext';

const Layout = ({ children }) => {
  const { show, toggle, content } = useSingleModal();
  // ...
  return (
    <>
      <div
        style={{
          margin: '0 auto',
          maxWidth: 960,
          padding: '0px 1.0875rem 1.45rem',
          paddingTop: 0,
        }}
      >
        <main>{children}</main>
        <footer>© {new Date().getFullYear()}, TekMi</footer>
        <SingleModal isOpen={show} toggle={() => toggle()} unmountOnClose={false}>
          {content}
        </SingleModal>
      </div>
    </>
  );
};

In here, we first import our SingleModal and the useSingleModal Custom Hook.

At the bottom of Layout render method, we add the SingleModal component. Important part here is the unmountOnClose prop set to false.

Because our SingleModal is actually a Reactstrap Modal, it's controlled by isOpen prop and toggled with toggle prop.

The content comes from React Context and is available to every Component on every Page.

Call the Modal from within the Gatsby page

Having the Modal mounted inside the "single-modal" DOM element, let's invoke it from the Gatsby index page. Open the src/pages/index.js and adjust it as presented below.

import React from 'react';
import 'bootstrap/dist/css/bootstrap.min.css';
import { Button, ModalBody, ModalFooter, ModalHeader, ButtonGroup } from 'reactstrap';
import { useSingleModal } from '../contexts/SingleModalContext';

const IndexPage = () => {
  const { toggle, close, setContent } = useSingleModal();

  const toggleModalWithContent = (cont) => {
    setContent(cont);
    toggle();
  };

  return (
    <>
      <ButtonGroup>
        <Button color="info" onClick={() => toggleModalWithContent(() => 'TekMi')}>
          Open Modal
        </Button>
        <Button color="warning" onClick={() => toggleModalWithContent(() => 11)}>
          Open Number Modal
        </Button>
        <Button color="success" onClick={() => toggleModalWithContent(() => [11, 22, 33])}>
          Open Array Modal
        </Button>
        <Button
          color="danger"
          onClick={() =>
            toggleModalWithContent(() => {
              return (
                <>
                  <ModalHeader>Modal title</ModalHeader>
                  <ModalBody>Another Test</ModalBody>
                  <ModalFooter>
                    <Button color="primary" onClick={() => close()}>
                      Do Something
                    </Button>{' '}
                    <Button color="secondary" onClick={() => close()}>
                      Cancel
                    </Button>
                  </ModalFooter>
                </>
              );
            })
          }
        >
          Open JSX Modal
        </Button>
      </ButtonGroup>
    </>
  );
};

export default IndexPage;

First, we import the bootstrap styles and Reactstrap Modal Components.

Then we import our useSingleModal Custom Hook and make use of it inside our local function toggleModalWithContent.

const { toggle, close, setContent } = useSingleModal();

const toggleModalWithContent = (cont) => {
  setContent(cont);
  toggle();
};

After this, we create several buttons that pass different content to our Single Modal. Please note that even JSX syntax is possible, making it a robust solution.


Exercise: Feel free to click the buttons and examine DOM tree inside your Developer Tools at the same time. You will notice that each Modal content renders inside "single-modal" DOM element, no matter which button you clicked. Even when you close the Modal, its HTML stays untouched - the only thing that changes is its display to none.


Looks like we have achieved what we wanted!

Please study the accompanying Gatsby with Modals repository to get a deeper understanding of this solution.

Summary

This has been a lengthy tutorial. It touched several core concepts, including React Context, React Portal and React Hooks. It used a lot of Gatsby SSR and Browser APIs.

It has demonstrated that it's possible to have only one DOM element and re-use it with the same Modal, swapping its content.

It is an experimental implementation, so don't be too hasty to use it in your codebase. It may not work if you are in a more sophisticated situation, like Modals on top of Modals.

If you see some Accessibility issues or a needless Re-renders, please let me know via Twitter.

My next step is to verify how Single Modal is going to work with the Redux and Custom Redux Middleware. Soon after, I'm planning to create a custom Modal, which will make Reactstrap and hacks around it obsolete.