Learn use React Portals - How to Develop a React Tooltip Component

Learn use React Portals - How to Develop a React Tooltip Component

This React Portals tutorial outlines a common use case for React Portal using a real-life example and learn how to develop a tooltip component from scratch. We’re going to take a look at a real world application for React Portals and explain how it can be helpful for solving the overflow:hidden problem on a tooltip example.

In this article, we’re going to take a look at a real world application for React Portals and explain how it can be helpful for solving the overflow:hidden problem on a tooltip example.

This is a very common problem that arises all the time in web development: you want to build some tooltip or dropdown, but it’s cut by the parent element overflow: hidden styling:

In the screenshot above, the parent container with the overflow:hidden style is marked with red and the element that is used for positioning is marked with green.

CSS/HTML solution (with downsides)

The simplest way to solve this issue is by simply removing the overflow styling:

The tooltip is now fully visible and everything looks good, but it becomes a very fragile solution when any of the following scenarios arise:

  1. Somebody could accidentally add overflow: hidden to the parent again (and forget to click your button with tooltip for testing!)
  2. Somebody could add another parent wrapper around it, for example, to introduce some extra styling in some cases.
  3. There is also the possibility that overflow: hidden was there for a reason, for example, to crop an image.

Here’s an example of an unwanted side effect of disabling overflow: hidden:
Before (image is inside the bounds of the card):

After (image has expanded far outside of the card marked with green):

React Portal in action

There’s a way to solve all the problems with tooltip/dropdown cut off by overflow for the entire application and reuse the code without needing to spend developer time on trying and testing.

The solution is to append tooltip or dropdown directly to the body of the document, set position: fixed style, and provide screenX and screenY coordinates where the tooltip/dropdown should appear.

Now, there are two things we need to do:

  1. Append the tooltip/dropdown to the body of the document outside of the React mount root
  2. Take coordinates for placing the tooltip/dropdown (for example, using useRef React hook)

Let’s start with mounting outside of React. That’s an easy task for a JQuery/Vanilla JS codebase, but it might sound challenging to a React developer because React applications usually have only one mount point to the DOM. For example, some div with id = "root".

Luckily, the React team introduced an additional way to mount components: React Portal.

Using React Portal, developers can access the tooltip/dropdown component from JSX in a convenient way: all of the props pass and handle events, but at the same time Portal is mounted to the body of the document outside of the React mount root.

The final JSX we are going to use is as follows:

    <Portal>
       <TooltipPopover coords={coords}>
          Awesome content that will never be cut off again!
       </TooltipPopover>
    </Portal>

In the code snippet above, the <Portal /> wrapper component takes care of mounting outside of React and <TooltipPopover/> is placed according to the coordinates passed to it. The final look is as follows:

That’s it: a universal solution for any content that should pop up outside of the parent without being cut off. But the <Portal/> wrapper component is a “black box” for us, so let’s change that and look at what’s under the hood.

Building a Portal wrapper

By following React docs for Portal we can build our own custom <Portal/> wrapper component from scratch in a few steps:

Step 1: Adding an extra mount point in a DOM outside of “react-root”

<html>
    <body>
        <div id="react-root"></div> // [ 1 ]
        <div id="portal-root"></div>
    </body>
</html>

In this code snippet, I have named the React mount point element id "react-root", and all of the tooltips/dropdowns should be mounted using React Portal inside of "portal-root".

Step 2: Build a reusable Portal wrapper component using createPortal in React

Here is a simplified <Portal/> wrapper component code written with React Hooks:

import { useEffect } from "react";
import { createPortal } from "react-dom";

const Portal = ({children}) => {
  const mount = document.getElementById("portal-root");
  const el = document.createElement("div");

  useEffect(() => {
    mount.appendChild(el);
    return () => mount.removeChild(el);
  }, [el, mount]);

  return createPortal(children, el)
};

export default Portal;

As you can see, mount needs a DOM element with id = "portal-root" from the previous code snippet with HTML to append an element inside. The core thing this wrapper component does is create a Portal for any React children passed into a component.

The useEffect React Hook is used here to take care of mounting the element at the right time and to clean up on component unmount.

Step 3: Passing button coordinates to the tooltip for positioning using React Hooks

The last thing we need to do to get the fully-functional tooltip component is pass button coordinates to the tooltip for positioning. That is not a hard task thanks to React Hooks, and it can be implemented with something like this:

const App = () => {
  const [coords, setCoords] = useState({}); // takes current button coordinates
  const [isOn, setOn] = useState(false); // toggles button visibility

  return <Card style={{...styles.card, overflow: "hidden"}}> // [ 2 ]
      <Button
        onClick={e => {
          const rect = e.target.getBoundingClientRect();
          setCoords({
            left: rect.x + rect.width / 2,
            top: rect.y + window.scrollY
          });
          setOn(!isOn); // [ 3 ]
        }}
      >
        Click me
      </Button>
      {
        isOn &&
        <Portal>
          <TooltipPopover coords={coords}>
            <div>Awesome content that is never cut off by its parent container!</div>
          </TooltipPopover>
        </Portal>
      }
  </Card>
}

In this code, the button component has an onClick event handler that takes current onscreen coordinates of the button from an e.target object using the standard getBoundingClientRect() method of a DOM element.

Additionally, a toggler for button visibility is in place that helps us to toggle the tooltip.

Please note that I left overflow: hidden intentionally on the Card component to showcase that the Portal solution is working fine.

Feel free to check the live demo and full code on codesandbox.

Bonus: prevent tooltips from “jumps” on page content change

There is one thing that refers to the tooltips positioning more than to Portals, but it’s worth mentioning: incase the button position depends on the right edge of the window (for example, display: flex; margin-left: auto styling), its positioning could be affected by the window scroll appearing (for example, when new content is loaded at the bottom of the page).

Let’s take a look at an example:

Before: window has no scroll and the tooltip is centered relative to the button.

After: window scroll has appeared, and the tooltip is a bit off center (exactly the same amount of pixels as the scroll added).

There are a few ways to solve this issue. You could use some resize detection package applied to the whole page like react-resize-detector, which will fire some event on content height change.

Then, we can measure the scroll width and correct the position of the tooltip.

Luckily, in our case, there is a much simpler pure CSS solution:

html {
    overflow-x: hidden;
    width: 100vw;
}

Adding this little code snippet to the page prevents the content of the page from unexpected “jumps” on window scroll appear/hide because the <html/> width is set to be equal to 100vw (window width), which is constant and unaffected by the window scroll.

Meanwhile, the 100% <html/> width doesn’t include the scroll, so the app doesn’t care anymore about the scroll being on or off. Tooltip will be centered all the time.

You can test the result on the demo https://xshnz.csb.app/ by playing with window height size.

Doing the same thing but with better-looking cross-browser scrollbars is also possible using a package called react-custom-scrollbars.

To make it work, you basically need to install the package and wrap the whole app into a Scrollbars component like this:

import { Scrollbars } from 'react-custom-scrollbars';

ReactDOM.render(
  <Scrollbars style={{ width: "100vw", height: "100vh" }}>
    <App />
  </Scrollbars>, 
  document.getElementById("react-root")
);

Here is a quick preview (note the scrollbar appearance):

Conclusion

We have gone through the most common use case for React Portal step by step, explaining how it works on a real-life example with tooltip component development from scratch.

Of course, generalization can’t come without its tradeoffs. The complexity of Portal tooltip is bigger than the pure CSS/HTML solution, and it’s up to the developer to choose the right approach at the right time.

Mapbox Marker Clustering in React

Mapbox Marker Clustering in React

Mapbox Marker Clustering in React. In this tutorial, we will load remote data using SWR, cluster the markers together with Supercluster and the useSupercluster hook, and render it in React using Mapbox.

Performance can begin to degrade pretty quickly when you are trying to show large amounts of data on a map. Even at hundreds of markers using Mapbox via react-map-gl, you may feel it start to lag. By clustering the points together you can improve performance greatly, all while presenting the data in a more approachable way.

Supercluster is the go-to package for clustering points together on a map. For using supercluster together with React I created a useSupercluster hook to make things easier. This article shows how to integrate clustering with supercluster into your React Mapbox map.

Mapbox setup in React

Before fetching data to display, before clustering that data to display on the map, we need to set Mapbox up. I have an intro to Mapbox video if you haven't worked with the react-map-gl package before.

Mapbox in React requires you to manage Mapbox's viewport in state. This is where we can set initial values which are later updated via the onViewportChange event.

We will also create a mapRef variable to store a reference to the map itself. This is required in order to call functions on the map, in our case to get the bounding box of the map.

When developing this locally, I am storing my Mapbox token in a file called .env.local, and by naming it with the prefix REACT_APP_, it will get loaded into the app automatically by create react app.

export default function App() {
  // setup map
  const [viewport, setViewport] = useState({
    latitude: 52.6376,
    longitude: -1.135171,
    width: "100vw",
    height: "100vh",
    zoom: 12
  });
  const mapRef = useRef();

  // load and prepare data
  // get map bounds
  // get clusters

  // return map
  return (
    <ReactMapGL
      {...viewport}
      maxZoom={20}
      mapboxApiAccessToken={process.env.REACT_APP_MAPBOX_TOKEN}
      onViewportChange={newViewport => {
        setViewport({ ...newViewport });
      }}
      ref={mapRef}
    >
      {/* markers here */}
    </ReactMapGL>
  );
}

Preparing data for supercluster

Data from an external/remote source will most likely need to be massaged into the format required by the supercluster library. The example below uses SWR to fetch remote data using hooks.

We must produce an array of GeoJSON Feature objects, with the geometry of each object being a GeoJSON Point.

An example of the data looks like:

[
  {
    "type": "Feature",
    "properties": {
      "cluster": false,
      "crimeId": 78212911,
      "category": "anti-social-behaviour"
    },
    "geometry": { "type": "Point", "coordinates": [-1.135171, 52.6376] }
  }
]

Fetching the data using SWR and converting it into the proper format looks like:

const fetcher = (...args) => fetch(...args).then(response => response.json());

export default function App() {
  // setup map

  // load and prepare data
  const url =
    "https://data.police.uk/api/crimes-street/all-crime?lat=52.629729&lng=-1.131592&date=2019-10";
  const { data, error } = useSwr(url, { fetcher });
  const crimes = data && !error ? data : [];
  const points = crimes.map(crime => ({
    type: "Feature",
    properties: { cluster: false, crimeId: crime.id, category: crime.category },
    geometry: {
      type: "Point",
      coordinates: [
        parseFloat(crime.location.longitude),
        parseFloat(crime.location.latitude)
      ]
    }
  }));

  // get map bounds
  // get clusters
  // return map
}
Getting map bounds

For supercluster to return the clusters based on the array of points we created in the previous section, we need to provide it with two additional pieces of information:

  • The map bounds: [westLng, southLat, eastLng, northLat]
  • The map zoom: In Mapbox this will come from our viewport.zoom state

The bounds can be gathered by accessing the mapRef.current property that we set up at the beginning. By stringing a few function calls together we can get the data and place it into the correct format.

export default function App() {
  // setup map
  // load and prepare data

  // get map bounds
  const bounds = mapRef.current
    ? mapRef.current
        .getMap()
        .getBounds()
        .toArray()
        .flat()
    : null;

  // get clusters
  // return map
}
Fetching clusters from hook

With our points in the correct order, and with the bounds and zoom accessible, it's time to retrieve the clusters. This will use the useSupercluster hook provided by the use-supercluster package.

It returns you through a destructured object an array of clusters and, if you need it, the supercluster instance variable.

export default function App() {
  // setup map
  // load and prepare data
  // get map bounds

  // get clusters
  const { clusters, supercluster } = useSupercluster({
    points,
    bounds,
    zoom: viewport.zoom,
    options: { radius: 75, maxZoom: 20 }
  });

  // return map
}

Clusters are an array of GeoJSON Feature objects, but some of them represent a cluster of points, and some represent individual points that you created above. A lot of it depends on your zoom level and how many points would be within a specific radius. When the number of points gets small enough, supercluster gives us individual points rather than clusters. The example below has a cluster (as denoted by the properties on it) and an individual point (which in our case represents a crime).

[
  {
    "type": "Feature",
    "id": 1461,
    "properties": {
      "cluster": true,
      "cluster_id": 1461,
      "point_count": 857,
      "point_count_abbreviated": 857
    },
    "geometry": {
      "type": "Point",
      "coordinates": [-1.132138301050194, 52.63486758501364]
    }
  },
  {
    "type": "Feature",
    "properties": {
      "cluster": false,
      "crimeId": 78212911,
      "category": "anti-social-behaviour"
    },
    "geometry": { "type": "Point", "coordinates": [-1.135171, 52.6376] }
  }
]
Displaying clusters as markers

Because the clusters array contains features which represent either a cluster or an individual point, we have to handle that while mapping them. Either way, they both have coordinates, and we can use the cluster property to determine which is which.

Styling the clusters is up to you, I have some simple styles applied to each of the markers:

.cluster-marker {
  color: #fff;
  background: #1978c8;
  border-radius: 50%;
  padding: 10px;
  display: flex;
  justify-content: center;
  align-items: center;
}

.crime-marker {
  background: none;
  border: none;
}

.crime-marker img {
  width: 25px;
}

Then as I am mapping the clusters, I change the size of the cluster with a calculation based on how many points the cluster contains: ${10 + (pointCount / points.length) * 20}px.

export default function App() {
  // setup map
  // load and prepare data
  // get map bounds
  // get clusters

  // return map
  return (
    <ReactMapGL>
      {clusters.map(cluster => {
        // every cluster point has coordinates
        const [longitude, latitude] = cluster.geometry.coordinates;
        // the point may be either a cluster or a crime point
        const {
          cluster: isCluster,
          point_count: pointCount
        } = cluster.properties;

        // we have a cluster to render
        if (isCluster) {
          return (
            <Marker
              key={`cluster-${cluster.id}`}
              latitude={latitude}
              longitude={longitude}
            >
              <div
                className="cluster-marker"
                style={{
                  width: `${10 + (pointCount / points.length) * 20}px`,
                  height: `${10 + (pointCount / points.length) * 20}px`
                }}
              >
                {pointCount}
              </div>
            </Marker>
          );
        }

        // we have a single point (crime) to render
        return (
          <Marker
            key={`crime-${cluster.properties.crimeId}`}
            latitude={latitude}
            longitude={longitude}
          >
            <button className="crime-marker">
              <img src="/custody.svg" alt="crime doesn't pay" />
            </button>
          </Marker>
        );
      })}
    </ReactMapGL>
  );
}
Animated zoom transition into a cluster

We can always zoom into the map ourselves, but supercluster provides a function called getClusterExpansionZoom, which when passed a cluster ID, it will return us which zoom level we need to change the map to in order for the cluster to be broken down into additional smaller clusters, or individual points.

const expansionZoom = Math.min(
  supercluster.getClusterExpansionZoom(cluster.id),
  20
);

With the next zoom level provided to us by supercluster, we could simple update our Mapbox viewport state, but it wouldn't be a smooth transition. react-map-gl provides a class called FlyToInterpolator which animates the map to the new zoom and lat/lon rather than the change being instant.

setViewport({
  ...viewport,
  latitude,
  longitude,
  zoom: expansionZoom,
  transitionInterpolator: new FlyToInterpolator({
    speed: 2
  }),
  transitionDuration: "auto"
});

Where do the snippets of code above live? I have put them inside of an onClick event on the Marker's div for each cluster being rendered.

<Marker key={`cluster-${cluster.id}`} latitude={latitude} longitude={longitude}>
  <div
    className="cluster-marker"
    style={{
      width: `${10 + (pointCount / points.length) * 20}px`,
      height: `${10 + (pointCount / points.length) * 20}px`
    }}
    onClick={() => {
      const expansionZoom = Math.min(
        supercluster.getClusterExpansionZoom(cluster.id),
        20
      );

      setViewport({
        ...viewport,
        latitude,
        longitude,
        zoom: expansionZoom,
        transitionInterpolator: new FlyToInterpolator({
          speed: 2
        }),
        transitionDuration: "auto"
      });
    }}
  >
    {pointCount}
  </div>
</Marker>
Conclusion

Using react-map-gl, we have the ability to use Mapbox within our React app. Using use-supercluster we are able to use supercluster as a hook to render clusters of points onto our map.

Because we have access to the instance of supercluster, we're even able to grab the "leaves" (the individual points which make up a cluster) via the supercluster.getLeaves(cluster.id) function. With this we can show details about the x number of points contained within a cluster.

Full source code of this project can be found here.

React Tutorial for Beginners - Learn React in 1 Hour

React Tutorial for Beginners - Learn React in 1 Hour

React Tutorial for Beginners - Learn React in 1 Hour. The quick guide to learn basic concepts and workflow of how to build React App. In this course you will learn React in 1 hour. You will learn what react does, you will learn how react works, you will learn views, components, state, routing, react lifecycle and much more.

React is not complicated as it seems and you can learn it quickly.

In this course you will learn React in 1 hour. This is not a code along and the styling is not important. The important things are - You will learn what react does, you will learn how react works, you will learn views, components, state, routing, react lifecycle and much more. You dont need 14 hours to learn react.

This course is 1 hour long and it will probably take you from 1 - 3 hours to finish it in a sense where you will understand what React does and how it works.

React Tutorial | Build a Simple CRUD App with ReactJS

React Tutorial | Build a Simple CRUD App with ReactJS

Learn ReactJS with this tutorial! React for both beginners