← home

React Hooks Modal

What to do on a cold and wet January weekend? Why not check out the new React alpha (16.8). The one with Hooks as it’s come to be called.

All it took was a little skimming through the docs, followed by updating react and react-dom.

yarn add react@next react-dom@next

That’s it. Ready to start coding. But what to do first? One thing that seemed a good fit for hooks are modals. I’d implemented them once or twice before and in both cases came away with the feeling that a class component with all its boilerplate is overkill considering the tiny bit of state management required for modal functionality. As expected, using hooks I was able to boil it down quite considerably. This is what I ended up with.

import React from 'react'

import { ModalBehind, ModalDiv, Close } from './styles'

const Modal = ({ open, closeModal, children }) => {
  return (
    <ModalBehind open={open} onClick={closeModal}>
      <ModalDiv onClick={event => event.stopPropagation()}>
        <Close onClick={closeModal} />
        {children}
      </ModalDiv>
    </ModalBehind>
  )
}

export default Modal

And here are the styled components imported on line 3.

import styled from 'styled-components'
import { Close as Cross } from 'styled-icons/material'

export const ModalBehind = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: grid;
  visibility: ${props => (props.open ? `visible` : `hidden`)};
  opacity: ${props => (props.open ? `1` : `0`)};
  transition: 0.5s;
  z-index: 2;
`

export const ModalDiv = styled.div`
  display: grid;
  align-items: center;
  box-sizing: border-box;
  place-self: center;
  background: var(--color-background);
  height: max-content;
  max-height: 80vh;
  width: 80vw;
  position: relative;
  overflow: scroll;
  border-radius: 0.5em;
  transition: 0.3s;
  box-shadow: 0 0 3em black;
  margin: calc(0.5em + 2vw);
`

export const Close = styled(Cross).attrs({ size: `2em` })`
  position: absolute;
  top: 0.5em;
  right: 0.4em;
  cursor: pointer;
`

As you can see, the styles are longer than the component itself. That’s where I spent most of my time too. Figuring out how to use React hooks took mere minutes. Props to the React team (pun intended) for the excellent onboarding experience!

Anyways, regarding usage, notice that the modal component doesn’t actually handle its own state. That’s done by the parent component. As an example here’s a list of photos that when clicked enter a higher-resolution modal view.

import React, { useState, Fragment } from 'react' // highlight-line

import Masonry from 'components/Masonry'
import Modal from 'components/Modal'

import { Thumbnail, LargeImg } from './styles'

export default function Photos({ photos }) {
  const [modal, setModal] = useState() // highlight-line
  return (
    <Masonry>
      {photos.map((img, index) => (
        <Fragment key={img.title}>
          <Thumbnail
            onClick={() => setModal(index)} // highlight-line
            alt={img.title}
            src={img.src}
          />
          // highlight-next-line
          <Modal open={index === modal} {...{ modal, setModal }}>
            <LargeImg alt={img.title} src={img.src} />
          </Modal>
        </Fragment>
      ))}
    </Masonry>
  )
}

So basically just 4 lines of code to control the list of modals (and 2 of those do other things as well). I have to say, I was pretty impressed by that. For comparison, this is how much code the class implementation needed (just the JS, no styles yet).

import React from 'react'
import { connect } from 'react-redux'

import { toggleModal } from '../redux/actions'
import './Modal.css'

class Modal extends React.Component {
  handleClickOutside = (event) => {
    if (this.node && !this.node.contains(event.target)) {
      const { dispatch, name } = this.props
      dispatch(toggleModal(name))
    }
  }

  componentDidMount() {
    if (this.props.closeOnClickOutside)
      document.addEventListener(`mousedown`, this.handleClickOutside)
  }

  componentWillUnmount() {
    document.removeEventListener(`mousedown`, this.handleClickOutside)
  }

  render() {
    const { modal, name, closeButton, toggleModal, children } = this.props
    if (!modal) return null
    return (
      <div ref={(node) => (this.node = node)} id={name + `-modal`}>
        {closeButton && (
          <button className="close-button" onClick={toggleModal}>
            &#10005;
          </button>
        )}
        {children}
      </div>
    )
  }
}

const mapStateToProps = (state, { name }) => {
  return {
    modal: state.modals[name],
  }
}

export default connect(mapStateToProps)(Modal)

Admittedly this component is bloated further by using Redux but even without it, it’s much harder to read and maintain. I’d call this use case a definite win for React Hooks!

Semantic HTML

One thing I should mention for future readers who want to use this Modal component: Once Chrome’s new <dialog> tag gets better browser support, it would improve semantics to use it for the modal container, i.e.

// previously: styled.div
export const ModalDiv = styled.dialog`
  ...;
`

and then maybe use the ::backdrop pseudo-element for the modal background.

export const ModalDiv = styled.dialog`
  ::backdrop {
    ...;
  }
`

However, bear in mind that using ::backdrop would make it more difficult to close the modal on clicks outside of it, i.e. on the background. This is because React is unable to attach onClick event handlers to pseudo-elements and it seems unlikely this will change down the road. A workaround would be to use the new useRef and useEffect hook to create an event listener on the browser’s window object that checks for the target of the click event. That would complicate things a little, though, since the listener would have to trigger on all clicks and check that the modal doesn’t include the target before closing. Something like so:

import React, { useRef, useEffect } from 'react'

import { ModalBehind, ModalDiv, Close } from './styles'

const handleClickOutside = (node, closeModal) => event => {
  if (node && !node.contains(event.target)) closeModal()
}

export default function Modal({ open, closeModal, children }) {
  const ref = useRef()
  useEffect(() => {
    const handler = handleClickOutside(ref.current, closeModal)
    document.addEventListener('mousedown', handler)
    return () => document.removeEventListener('mousedown', handler)
  })
  return (
    <ModalDiv ref={ref} open={open}>
      <Close onClick={closeModal} />
      {children}
    </ModalDiv>
  )
}

Keyboard controls

If you have a list of modals and you’d like users to be able to go to the next or previous modal using the arrow keys, you can add an event listener with the useEffect hook for this as well.

import React, { useEffect } from 'react'

import { ModalBehind, ModalDiv, Close, Next, Prev } from './styles'

// highlight-start
const handleArrowKeys = (modal, setModal) => event => {
  if (event?.key === `ArrowRight`) setModal(modal + 1)
  else if (event?.key === `ArrowLeft`) setModal(modal - 1)
  else if (event?.key === `Escape`) setModal()
}
// highlight-end

export default function Modal({ open, modal, setModal, ...rest }) {
  // highlight-start
  const { showArrows, className, children } = rest
  useEffect(() => {
    const handler = handleArrowKeys(modal, setModal)
    document.addEventListener(`keydown`, handler)
    return () => document.removeEventListener(`keydown`, handler)
  })
  // highlight-end
  if (open) {
    return (
      // Passing setModal to onClick without arguments implicitly
      // sets to undefined, i.e. closes the modal.
      <ModalBehind open={open} onClick={setModal}>
        <ModalDiv onClick={event => event.stopPropagation()} className={className}>
          <Close onClick={setModal} />
          // highlight-start
          {showArrows && (
            <>
              <Next onClick={() => setModal(modal + 1)} />
              <Prev onClick={() => setModal(modal - 1)} />
            </>
          )}
          // highlight-end
          {children}
        </ModalDiv>
      </ModalBehind>
    )
  } else return null
}

export default Modal

The new styled components Next and Prev share most of their CSS with Close so it makes sense to reuse that:

import styled, { css } from 'styled-components'

import { Close as Cross, NavigateNext, NavigateBefore } from 'styled-icons/material'

const controlsCss = css`
  position: absolute;
  cursor: pointer;
  z-index: 1;
  color: ${props => props.white && `white`};
  background: ${props => props.white && `rgba(0, 0, 0, 0.5)`};
  border-radius: 50%;
  padding: 0.1em;
  transition: 0.3s;
  :hover {
    transform: scale(1.07);
  }
`

export const Close = styled(Cross).attrs({ size: `2em` })`
  ${controlsCss};
  top: 0.5em;
  right: 0.4em;
`

export const Next = styled(NavigateNext).attrs({ size: `2em` })`
  ${controlsCss};
  top: 50%;
  right: 0.4em;
`

export const Prev = styled(NavigateBefore).attrs({ size: `2em` })`
  ${controlsCss};
  top: 50%;
  left: 0.4em;
`

For the full and most up to date implementation of this component, check out this site’s Github repo.