update dom ref using d3 and react

Viewed 1524

I have a functional component with a d3 function which appends a p to the ref on mount

Im trying to change the text content of the appended paragraph using the useState hook

clicking the button <button type="button" onClick={(e)=> setData("bar")}>change data</button> triggers a re-render, instead of changing the text of the p tag , a new version of the d3 function gets created --> appends a new p

reading How to rerender when refs change suggest to use the useCallback hook which im not quit sure how implement

how to achive a a re-render of the paragraph without creating a new one?

import {useEffect, useState, useRef} from 'react'

import * as d3 from 'd3'

function DomReference() {

  const domRef = useRef()

  const [data, setData] = useState("foo")

  useEffect(()=>{


    d3.select(domRef.current)
      .append('p')
      .text(data)
    
    // re-render --> creates a new function 
    
  }, [data])


  return (
    <div ref={domRef}>
      <button type="button" onClick={(e)=> setData("bar")}>change data</button>
      
    </div>
  )
}

export default DomReference

2 Answers

The d3 append function will always create a new element whenever it is executed.

To have always one paragraph and only update its value, you can use d3.join.

instead of:

  d3.select(domRef.current)
      .append('p')
      .text(data)

You could use:

  d3.select(domRef.current).selectAll('p')
    .data([data])
    .join('p')
    .text(d => d)

Explaining line by line:

  • d3.select(domRef.current).selectAll('p'): Selects all the <p> elements in domRef.current
  • .data([data]): binds the <p> elements to an array of data. When you bind, each item in the array will have its own <p>.
  • .join('p'): The join will create a selection that removes all the <p> that do not have data associated with, and will update the ones that have data. This will leave you with one <p>, since the .data() call before only had one element in the array
  • .text(d => d): This callback is how you access the data that is associated with the <p>.

As a complement:

if you don't want to remove all the other <p>, you can use a class to narrow your selection:

  d3.select(domRef.current).selectAll('p.myOddParagraphs')
    .data([1, 3, 5])
    .join('p')
    .classed('myOddParagraphs', true)
    .text(d => d)

   d3.select(domRef.current).selectAll('p.myEvenParagraphs')
    .data([2, 4, 6])
    .join('p')
    .classed('myEvenParagraphs', true)
    .text(d => d)

The code above would result in 6 paragraphs: three are bound to the [1, 3, 5] array, and the other three are bound to [2, 4, 6]

In principle, you shouldn't attempt to mutate DOM using D3 while using React, because React would be unaware of such changes. This usually leads to very nasty bugs that are difficult to spot and debug. If you find yourself using attr or append in React, you're using it wrong ;)

This is not to say you can't use D3 with React though. The correct way is to always leave DOM manipulations to React, and use D3 as engine that computes your properties. E.g. when using d3 force layout, you can compute (x,y) coordinates using D3, and pass them as props, so that React handles reconciliation etc.

In the above example, I'd suggest leaving the rendering of <p .../> to React. I don't know however what is your end goal so can't really tell what's the correct way, apart from converting this example trivially to React

Related