JR

Pages

Blog Posts

Powered by Algolia
nature-photos-masonry

React Hooks Masonry

Mar 14, 20191 min readDesign, Web Dev, Tutorial, JS

Now that we have React Hooks, so many components can (and should) be rewritten in a more succinct, readable and maintainable manner (despite what Dan said at React Conf). A perfect rewrite candidate in the code base of this site was a rather brittle Masonry component that was centered around CSS grid. With hooks, it was easy to significantly improve on this approach.

For those unfamiliar with “masonry” on the web, the goal is to create a layout like this.

1
4
7
10
13
16
2
5
8
11
14
17
3
6
9
12
15
18

Excluding the useEventListener hook and styles it uses, the new implementation uses only 29 lines of codes and is about as plug-and-play as components get.

src/components/masonry/index.js
import React, { useEffect, useRef, useState } from 'react'
import { useEventListener } from 'hooks'
import { Col, MasonryDiv } from './styles'

const fillCols = (children, cols) => {
  children.forEach((child, i) => cols[i % cols.length].push(child))
}

export default function Masonry({ children, gap, minWidth = 500, ...rest }) {
  const ref = useRef()
  const [numCols, setNumCols] = useState(3)
  const cols = [...Array(numCols)].map(() => [])
  fillCols(children, cols)

  const resizeHandler = () =>
    setNumCols(Math.ceil(ref.current.offsetWidth / minWidth))
  useEffect(resizeHandler, [])
  useEventListener(`resize`, resizeHandler)

  return (
    <MasonryDiv ref={ref} gap={gap} {...rest}>
      {[...Array(numCols)].map((_, index) => (
        <Col key={index} gap={gap}>
          {cols[index]}
        </Col>
      ))}
    </MasonryDiv>
  )
}

The useEventListener hook looks like this:

hooks/useEventListener.js
import { useEffect, useRef } from 'react'

export function useEventListener(eventNames, handler, element = globalThis) {
  // Create a ref that stores the handler.
  const savedHandler = useRef()
  if (!Array.isArray(eventNames)) eventNames = [eventNames]

  // Save handler to ref.current on initial call to useEventListener
  // and then update ref.current whenever the handler changes.
  // This allows the second useEffect below to always get the latest
  // handler without needing to have it in than hooks deps array which
  // could cause the effect to re-run every render.
  useEffect(() => (savedHandler.current = handler), [handler])

  useEffect(() => {
    if (!element.addEventListener) return // element doesn't support a listener, abort.

    // Create event listener that calls handler function stored in ref
    const listener = event => savedHandler.current(event)
    for (const e of eventNames) element.addEventListener(e, listener)
    return () => {
      for (const e of eventNames) element.removeEventListener(e, listener)
    }
  }, [element, eventNames])
}

The styled components MasonryDiv and Col each create a CSS grid to layout children with a default gap of 1em. You can pass any valid CSS units as a string as well as different values for vertical and horizontal gap, e.g. <Masonry gap="calc(2vh + 20px) calc(1vw + 20px)" />.

src/components/masonry/styled.js
import styled from 'styled-components'

export const MasonryDiv = styled.div`
  display: grid;
  grid-auto-flow: column;
  grid-gap: ${props => props.gap || `1em`};
`

export const Col = styled.div`
  display: grid;
  grid-gap: ${props => props.gap || `1em`};
`

Examples

Using Masonry is as simple as wrapping it around an array of child elements. Here’s how you’d use it to display a list of image thumbnails.

import React from 'react'
import Masonry from 'components/Masonry' 
import { Thumbnail } from './styles'

export const Photos = ({ photos }) => (
  <Masonry>
    {photos.map(img => (
      <Thumbnail key={img.title} alt={img.title} src={img.src} />
    ))}
  </Masonry> 
)

The interactive colored tiles above are rendered by this component.

ColorMasonry.js
import React, { useState } from 'react'
import Masonry from 'components/Masonry' 
import shuffle from 'lodash/shuffle'
import styled from 'styled-components'

const ColorBox = styled.div`
  border-radius: 1em;
  transition: 0.2s;
  place-content: center;
  display: grid;
  color: white;
  cursor: pointer;
  :hover {
    transform: scale(1.1);
    box-shadow: 0 0 12px 0 var(--color-shadow);
  }
`

const data = [
  [`5em`, `linear-gradient(45deg, #f05f70, #164b78)`],
  [`2em`, `linear-gradient(45deg, #5cb767, #2e9fff)`],
  [`4em`, `linear-gradient(45deg, #e0c3fc, #8ec5fc)`],
  [`7em`, `linear-gradient(45deg, #f093fb, #f5576c)`],
  [`1em`, `linear-gradient(45deg, #ffd34f, #2e9fff)`],
  [`3em`, `linear-gradient(45deg, #d299c2, #fef9d7)`],
  [`2em`, `linear-gradient(45deg, #f6d365, #fda085)`],
  [`5em`, `linear-gradient(45deg, #164b78, #ffd34f)`],
  [`5em`, `linear-gradient(45deg, #96fbc4, #f9f586)`],
]

export default function MasonryExample() {
  const [divs, setDivs] = useState(data.concat(data))
  return (
    <Masonry minWidth={300} css="margin: 2em 0;">
      {divs.map(([minHeight, background], index) => (
        <ColorBox
          key={index}
          style={{ background, minHeight }}
          onClick={() => setDivs(shuffle)}
        >
          {index + 1}
        </ColorBox>
      ))}
    </Masonry> 
  )
}
© 2020 - Janosh RiebesellRSSThis site is open source
Powered by