February 25, 2019
Edit: You might want to use Floating UI now, the modern successor to PopperJS (by the same team)
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:
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