DEV Community

Cover image for Creating a Custom D3 or P5 Hook in React
Christian
Christian

Posted on

Creating a Custom D3 or P5 Hook in React

I'd recently been exploring how useful custom hooks in React could be and decided to try my hand at them to conserve and maintain code like any good programmer. I'd realized that the solution for integrating D3 is just about the same way to integrate p5 (useRef with useEffect to let d3 and p5 do their DOM magic) so I went about coding a custom hook to port these two bad boys into any current or future React apps I work on.

To the Races!

Now the short and sweet of it is this guy right here:

const useDOMControl = (domFunc) => {
  const domRef = useRef()

  useEffect(() => {
    domFunc(domRef.current)
  })

  return (
    <div ref={domRef}>
    </div>
  )
}

The typical way of declaring a hook is prefixing the name with "use" (e.g. "useEffect", "useState", etc.) and so I've named this guy "useDOMControl" because that's exactly what's necessary for us to use P5 or D3 within React. There are some other solutions for D3 that make use of D3 for solely calculations and no DOM manipulation, but this way keeps d3 stuff somewhat insulated from React and the virtual DOM. For P5, we need to resort to instance mode and feed it a reference node in the same exact way that D3 does.

Walking down line-by-line, we see that the hook takes in a "domFunc" variable as an argument and applies that argument within our useEffect hook. This domFunc will contain exactly the kinds of things we'd normally do within a D3 visualization or p5 instance mode sketch. But that's getting ahead of ourselves.

Then we declare a reference variable called "domRef" using React's useRef hook. This just let's our other libraries have a node or reference insertion point. It's important that we pass "domRef.current" into domFunc or our DOM function because ".current" gives the actual HTML node we want. And finally, we return some jsx of a div that has the ref attribute equal to the value of our useRef hook variable.

That's pretty much all there is to the actual hook, but a use case must follow a specific pattern.

P5

Getting into the actual component, I've written an App component that makes use of our custom hook and writes a very simple p5 sketch:

function App() {

  const p5Function = (p5Ref) => {
    const sketch = p => {
      p.setup = () => {
        p.createCanvas(400,400)
        p.background(0)
      }

      p.draw = () => {
        p.fill(255)
        p.ellipse(p.width/2,p.height/2,400)
      } 
    }

    new p5(sketch, p5Ref)
  } 

  return (
    <div className="App">
      {useDOMControl(p5Function)}
    </div>
  );
}

p5 sketch

So from top to bottom we initialize a p5 function that takes in a DOM node as an argument. We pass in this p5 function into our useDOM control hook in the App return line because the hook itself returns jsx, specifically a div containing our p5 sketch or d3 visualization.

The rest of the p5 function declares a p5 sketch in instance mode saved as "sketch" and then passes that instance mode sketch into a new p5 instance along with the HTML node variable we're using as an argument. Remember that we pass the p5 function into the useDOMControl hook, which then calls it with the useRef variable. Hook inception, I know.

D3

The same sort of pattern applies here where we'll create a d3Function that takes in the HTML node where it'll be placed:

import alphabet from "./assets/alphabet.csv"

function App() {

const d3Function = (d3Ref) => {
    d3.csv(alphabet).then(csv => {
      const data = Object.assign(csv.map(({letter, frequency}) => ({name: letter, value: +frequency})).sort((a, b) => d3.descending(a.value, b.value)), {format: "%", y: "↑ Frequency"})
      const color = "steelblue"
      const height = 500
      const width = 500
      const margin = ({top: 30, right: 0, bottom: 30, left: 40})
      const svg = d3.select(d3Ref)
        .append("svg").attr("viewBox", [0, 0, width, height]);

      const y = d3.scaleLinear()
      .domain([0, d3.max(data, d => d.value)]).nice()
      .range([height - margin.bottom, margin.top])

      const x = d3.scaleBand()
      .domain(d3.range(data.length))
      .range([margin.left, width - margin.right])
      .padding(0.1)

      const yAxis = g => g
      .attr("transform", `translate(${margin.left},0)`)
      .call(d3.axisLeft(y).ticks(null, data.format))
      .call(g => g.select(".domain").remove())
      .call(g => g.append("text")
          .attr("x", -margin.left)
          .attr("y", 10)
          .attr("fill", "currentColor")
          .attr("text-anchor", "start")
          .text(data.y))

      const xAxis = g => g
      .attr("transform", `translate(0,${height - margin.bottom})`)
      .call(d3.axisBottom(x).tickFormat(i => data[i].name).tickSizeOuter(0))

      svg.append("g")
          .attr("fill", color)
        .selectAll("rect")
        .data(data)
        .join("rect")
          .attr("x", (d, i) => x(i))
          .attr("y", d => y(d.value))
          .attr("height", d => y(0) - y(d.value))
          .attr("width", x.bandwidth());

      svg.append("g")
          .call(xAxis);

      svg.append("g")
          .call(yAxis);
      return svg.node();
    })
  }

  return (
    <div className="App">
      {useDOMControl(d3Function)}
    </div>
  );
}

d3 Bar chart

This one is a little bit complicated with respect to the actual d3 material but I just adapted a Mike Bostock bar chart to show there isn't much of a problem here. We're able to select the HTML node and append all the svg goodies that we want to make a full fledged graph.

Hope that was helpful and useful. I've done a blog in the past about integrating p5 and React but with class components. As you can see, a functional approach is even easier and provides some nice reusability for a code base.

Top comments (1)

Collapse
 
vennsoh profile image
Ee Venn Soh

That look awesome. I'm trying to get it working but in vain. Do you have an example code of how this could work?