D3 + React hooks + useEffect() - prevent duplicate calls to d3.append() on mouseout

Viewed 359

Hi everyone here's a fun one with D3, topojson, and React hooks.

I'm hung up on removing the instance of a tooltip that gets created on mouseover and persists on mouseout -- the "React hooks" way of working with D3 is to use useEffect(), the svg is rendered with useRef(), and that works fine, but each successive mouseover event triggers a new .append that adds another div so the tooltip content just keeps getting piled on.

I thought that using D3's selection.join() might be the way to handle this, but not working yet. I've definitely considered implementing a completely separate tooltip component in React that shares context with the chart component but first I'm hoping someone might have an idea about how to do this with D3 since it's close.

How can the "extra" div content getting added with .append be removed on mouseout with D3?

Working example on Codesandbox here.

Relevant code:

    svg
      .append("g")
      .selectAll("path")
      .data(features)
      .join(
        (enter) =>
          enter
            .append("path")
            .attr("fill", (d) =>
              colorScale(turnoutPerc.get(d.properties.GEOID))
            )
            .attr("d", path)
            .on("mouseover", (event, d) => {
              d3.select(event.currentTarget)
                .raise()
                .style("stroke", "#000")
                .style("stroke-width", 2)
                .style("cursor", "pointer");
              event.preventDefault();
              let [x, y] = d3.pointer(event);
              let ttPos = positionTooltip(x, y);
              let countyData = {
                showTT: true,
                FIPS: d.properties.GEOID,
                mouse: { x: ttPos.x, y: ttPos.y }
              };
              updateCurrentCounty(countyData);
              let countyProps = getCountyProps(countyData, data);
              container
                .style("visibility", "visible")
                .style("top", () => {
                  let ttHeight = 400;
                  if (y < ttHeight / 2) {
                    return event.pageY - 50 + "px";
                  } else if (y >= ttHeight / 2 && y < height - ttHeight / 2) {
                    return event.pageY - ttHeight / 2 + "px";
                  } else {
                    return event.pageY - ttHeight + "px";
                  }
                })
                .style("left", event.pageX + 60 + "px");

                // Header
                const header = container
                  .append("div")
                  .attr("class", "tt-header");
                const location = header
                  .append("div")
                  .attr("class", "tt-location");
                location
                  .append("h1")
                  .attr("class", "tt-county")
                  .html(countyProps.countyName);
                location
                  .append("h2")
                  .attr("class", "tt-state")
                  .html(countyProps.state);
            })
            .on("mouseout", (event, d) => {
              updateCurrentCounty({
                showTT: false,
                FIPS: null
              });
              d3.select(event.currentTarget)
                .style("stroke", "none")
                .style("cursor", "default");
            }),
        (update) => update.call((update) => update.transition(t)),
        (exit) => exit.remove()
      );

enter image description here

1 Answers

Add:

container.selectAll(".tt-header").remove();

Before:

const header = container.append("div").attr("class", "tt-header");

Here is a fixed sandbox.

Related