Carrots

Integrate Popper.js in React

February 25, 2019

Until recently I used the library react-popper to add poppers (i.e. floating elements, anchored to a target) in my pages using Popper.js.

Hooks makes it easy to do it yourself directly. With this article you should be able to take a scaffold of a basic usePopper hook and modify it for your specific needs. We follow an approach inspired by react-popper.

We have a menu component that we want to display next to a target. Because we can, we introduce an arrow as well. Assuming that we have the components already built, we need to:

  1. initialise Popper.js with references
  2. retrieve the changed style that it computes

So this is what how our API will look like as well

const PopperComponent = () => {
  const [targetNode, setTargetNode] = useState(null);
  const [menuNode, setMenuNode] = useState(null);
  const [arrowNode, setArrowNode] = useState(null);

  const { styles, placement, arrowStyles } = usePopper({
    referrenceNode: targetNode,
    popperNode: menuNode,
    arrowNode,
  });

  return (
  <>
    <Target ref={setTargetNode}>
    <Portal>
      <Popper ref={setMenuNode} style={styles} data-placement={placement}>
        <Menu />
        <Arrow ref={setArrowNode} style={arrowStyles} />
      </Popper>
    </Portal>
  </>
  );

Note that we use useState and not useRef to get to the reference of the DOM node, why?. With useRef we would not refresh our popper on the change of node (which would be an issue). Here we actually use a simple version of this example from the React FAQ, where the ref function is just a setState.

Note that I like to use react-portal to create a Portal element – it makes sure whatever is in the popper is not influenced by the styling from parent elements. But this is not strickly necessary.

So what does usePopper look like? If we go with the most simple configuration we have the following hook.

const usePopper ({
  referrenceNode,
  popperNode,
  arrowNode,
  placement,
}) => {
  const [popperStyles, updatePopperState] = usePopperState(placement);
  const popperInstance = useRef();

  // manage the popper instance lifecycle
  useEffect(
    () => {
      // We refresh the popper instance on every config change
      if (popperInstance.current) {
        popperInstance.current.destroy();
        popperInstance.current = null;
      }

      if (!referrenceNode || !popperNode) return;

      popperInstance.current = new PopperJS(
        referrenceNode,
        popperNode,
        {
          placement,
          modifiers: {
            arrow: {
              enabled: !!arrowNode,
              element: arrowNode,
            },
            applyStyle: {
              // we manage to apply the styles ourselves
              enabled: false
            },
            updateStateModifier: {
              enabled: true,
              order: 900,
              fn: updatePopperState,
            },
          },
      });
      popperInstance.current.enableEventListeners();

      return () => {
        popperInstance.current.destroy();
        popperInstance.current = null;
      };
    },
    [ arrowNode, referrenceNode, popperNode, placement ]
  );

  useEffect(() => {
    if (popperInstance.current) {
      popperInstance.current.scheduleUpdate();
    }
  });

  return popperStyles;
};

What do we do? We have a popper instance that generates a style configuration on each refresh. This style configuration is part of the state. On every change of the styles a render will be triggered.

With useEffect we refresh the configuration of the popper. This means that poppers will always take into account the latest configuration (or latest targets if they change).

Finally we make sure the popper position is always scheduled when the component is re rendered.

What does usePopperState do? It is a simple mapper between what popper.js returns and what we want to export as the usePopper API.

const usePopperState = placement => {
  const [currentStyles, setStyles] = useDiffedState({
    position: 'absolute',
    top: 0,
    left: 0,
    opacity: 0,
    pointerEvents: 'none',
  });
  const [currentArrowStyles, setArrowStyles] = useDiffedState({});
  const [currentOutOfBoundaries, setOutOfBoundaries] = useState(false);
  const [currentPlacement, setPlacement] = useState(placement);

  const updatePopperState = updatedData => {
    const {
      styles,
      arrowStyles,
      hide,
      placement: updatedPlacement,
    } = updatedData;

    setStyles(styles);
    setArrowStyles(arrowStyles);
    setPlacement(updatedPlacement);
    setOutOfBoundaries(hide);
    return updatedData;
  };

  const popperStyles = {
    styles: currentStyles,
    placement: currentPlacement,
    outOfBoundaries: currentOutOfBoundaries,
    arrowStyles: currentArrowStyles,
  };

  return [popperStyles, updatePopperState];
};

You could modify it to allow for a more detailed management of the default values for both the default style of the popper or the arrow.

useDiffedState is a helper hook that allows us to refresh our state only when necessary (it might be premature optimisation, but hey it was fun). This exposes the API from useState, but refreshes objects only if their value is different.

import { useState } from 'react';
import isEqual from 'lodash/isEqual';
import isFunction from 'lodash/isFunction';

const useDiffedState = initVal => {
  const [storedValue, setStoredValue] = useState(initVal);

  const setValue = value => {
    const valueToStore = isFunction(value) ? value(storedValue) : value;
    setStoredValue(prevState => {
      if (isEqual(prevState, valueToStore)) {
        return prevState;
      }
      return valueToStore;
    });
  };
  return [storedValue, setValue];
};

This is just a start, because Popper.js allows you to configure a lot what you might want to do. For instance, you might want to configure if the position is fixed, or work with modifiers.

You can find a more complete example published here.

Discuss it on Mastodon

Edit March 22, 2019: Put the DOM node as state instead of ref, as it can change. Thanks to this comment


Personal blog by Steve Genoud.
Carrots are good for your health.