useTracker + D3 integration

I’m trying to follow these awesome tutorials on using D3 with hooks:

The problem is that I can’t make the svg component reactive to changes, say the radius of some circles.

I’ll post some code to further illustrate my problem:

const D3Animatables = () => {
  const { subs_animatables, animatables } = useTracker(() => {
     const 
          subs_animatables = Meteor.subscribe("animatablesPub"),
          animatables = Animatables.find().fetch()
      ;
      return {
        subs_animatables,
        animatables
      };
  });

  const svgRef = useRef();

  useEffect(() => {
    const svg = select(svgRef.current);
    
    svg
      .selectAll("circle")
      .data(animatables)
      .join(
        enter => enter
          .append("circle")
          .attr("r", a => a.radius)
          .attr("cx", a => 100 * (animatables.indexOf(a) + 1))
      )
  }, [animatables]);

  return <svg ref={svgRef}></svg>;

I’ve read @captainn’s posts about the awesome new useTracker and have used it in other projects with no problems whatsoever. But here, I feel like I’m missing something. Can anybody help me sort this out? :crossed_fingers:

The D3 method chain is trying to manually update the DOM, and React loses track of its internal representation. Look into portals if you want low-level granular control. Otherwise, try using a library like Nivo, which has already put common D3 graphs into reusable React components using portals. useTracker will work as expected with the Nivo libraries.

1 Like

My guess is you have something of a race condition, and a useEffect deps config issue here.

You don’t want the useEffect hook to run until the animatables array is loaded. But you don’t have any loading logic here. You probably want to check the subscription’s ready() method and send the result of that to the useEffect hook, and do nothing until it’s loaded.

const D3Animatables = () => {
  const { animSubReady, animatables } = useTracker(() => {
     const 
          subs_animatables = Meteor.subscribe("animatablesPub"),
          animatables = Animatables.find().fetch()
      ;
      return {
        animSubReady: subs_animatables.ready(),
        animatables
      };
  });

  const svgRef = useRef();

  useEffect(() => {
    if (!animSubReady) return;

    const svg = select(svgRef.current);
    
    svg
      .selectAll("circle")
      .data(animatables)
      .join(
        enter => enter
          .append("circle")
          .attr("r", a => a.radius)
          .attr("cx", a => 100 * (animatables.indexOf(a) + 1))
      )
  }, [animatables, animSubReady]);

  return <svg ref={svgRef}></svg>;

Additionally, I don’t think the animatables prop will trigger a rerun of the useEffect hook anyway, because I doubt that’s immutable (I actually don’t recall if it gets you a new array ref or not). You’ll probably want to pass in the specific props you are accessing inside the useEffect hook instead of the containing object ref.

1 Like

@awatson1978, I’m not sure of the strategy I should follow to solve the problem using portals. I didn’t know about Nivo and although it looks very nice and curated, I need pure D3 on this.

@captainn

It works the same with the loading logic but it’s a neat detail :slight_smile:
I think you struck on the core problem here:

It does work for entering and exiting elements but not for properties changes :confused: so I’m guessing useEffect is overlooking the actual radius change.

And this made me realize that my actual code is not exactly like the one I posted and that is probably the actual key of it all. Ok, let me explain. When I wrote

enter => enter
          .append("circle")
          .attr("r", a => a.radius)

, the actual code was this:

enter => enter
            .append("circle")
            .attr("r", a => growthFactor * a.reanimationsList.filter(v => v.action === "inc").length - (growthFactor * a.reanimationsList.filter(v => v.action === "dec").length) - (((new Date() - a.reanimationsList[a.reanimationsList.length - 1].time) / 1000 / 60 / 60 / 24 ) * decayFactor))

It all comes down to the point that the property “radius” is in fact an array of objects that actually determines the final value of the radius for each circle. I don’t think useEffect is deep-comparing the values of that array or the values contained in those objects… this could be it, right?

I found this article https://dev.to/w1n5rx/ways-to-handle-deep-object-comparison-in-useeffect-hook-1elm but failed trying to apply those solutions.

useEffect does definitely not do a deep compare - it’s shallow. You can overcome this by passing the object properties you want to access on objects instead of the object reference itself. For arrays it’s trickier, and you’ll either want to control immutability yourself (easy way - always clone the array after .fetch() and return that clone in useTracker) or trigger the rerun of useEffect off another value, like the loading flag.

// immutable-ish
const { animSubReady, animatables } = useTracker(() => {
     const 
          subs_animatables = Meteor.subscribe("animatablesPub"),
          animatables = [...Animatables.find().fetch()]
      ;
      return {
        animSubReady: subs_animatables.ready(),
        animatables
      };
  });
1 Like

Problem dissolved by using the simplified join API. :man_shrugging:

useEffect(() => {

    if (!animSubReady) return;
    const svg = select(svgRef.current);

    svg
      .selectAll("circle")
      .data(animatables)
      .join("circle")
        .transition()
        .attr("r", a => a.radius)
        .attr("cx", a => 100 * (animatables.indexOf(a) + 1))
        .attr("cy", a => 50)
        .attr("stroke", "white")
        .attr("fill", a => a.color)
    ;
}, [animatables]);
1 Like