React for Data Visualization
Student Login
  • Introduction

Responsive SVG drawing with React and useLayoutEffect

How do you connect grid or flexbox positioned elements with a curvy line?

SVG lines connecting complex interactive elements

Here's a technique I have to re-learn every time. Writing it down for next time πŸ˜…

Swizec Teller writing a secret book avatarSwizec Teller writing a secret book@Swizec
getBoundingClientRect() is giving me nonsense co-ordinates and I can't figure out why

2 hours ago this was supposed to be easy
Tweet media

PS: if you're into this sort of thing, check out React for Dataviz where I teach how to expertly wield React for playfully visual components

The problem

Button is hiding, line is delayed

You have complex interactive elements on the page. You don't know how big they are or how complex. Rendering with SVG is out of the question.

You used flexbox or grid to neatly position them in a <div>. Lets you leverage the browser's layouting abilities and renders perfectly on every screen.

Like this:

They're buttons in this example. Later we'll use that to make the example harder – dynamic elements πŸ˜…

Now assume this is a data visualization of some sort. The elements are points on a graph, a tree data structure, or you need visual flourish to make it pretty.

How do you draw complex lines between elements you can't control? SVG hates uncertainty. πŸ€”

SVG drawing synced to DOM elements

To draw an SVG line between elements you don't control, you'll need a couple ingredients:

  1. An SVG canvas that sits behind your elements
  2. DOM Refs to the elements
  3. Use getBoundingClientRect() to measure size and position

getBoundingClientRect measures the size and position of your element relative to the screen. This part gets me every time.

Swizec Teller writing a secret book avatarSwizec Teller writing a secret book@Swizec
You have to take the parent's getBoundingClientRect and adjust your coordinates by its position

I forget and re-learn every time πŸ€¦β€β™€οΈ

You have to subtract the SVG's getBoundingClientRect() position to get coordinates relative to your canvas.

CurvyLine component

A component that draws a line between 2 elements looks like this:

const CurvyLine = ({ sourceRef, targetRef, canvasRef }) => {
const sourceRect = sourceRef?.current?.getBoundingClientRect()
const targetRect = targetRef?.current?.getBoundingClientRect()
const canvasRect = canvasRef?.current?.getBoundingClientRect()
if (!sourceRect || !targetRect || !canvasRect) {
// avoid render when refs undefined
return null
}
// bezier curve generator
const curve = d3.linkHorizontal()
// calculate positions
const source = [
sourceRect.left + sourceRect.width / 2 - canvasRect.left,
sourceRect.top + sourceRect.height / 2 - canvasRect.top,
]
const target = [
targetRect.left + targetRect.width / 2 - canvasRect.left,
targetRect.top + targetRect.height / 2 - canvasRect.top,
]
return <path d={curve({ source, target })} stroke="black" fill="none" />
}

We draw the curve in 5 steps:

  1. Measure all the elements we need – source, target, and canvas
  2. Bail if the refs aren't ready. React doesn't have the DOM elements on its first rendering pass. This is important later, you'll see πŸ’‘
  3. Use d3.linkHorizontal to generate the curve
  4. Prepare SVG coordinates for the curve – making sure to subtract the canvas coordinates
  5. Render a <path> element

Rendering the CurvyLine

We use an absolutely positioned SVG element to create a canvas that sits behind our elements.

<div className="grid-2-cols">
<!-- position: absolute takes it out of the grid -->
<svg className="absolute-svg"></svg>
<div className="column">
<button>Button 1</button>
<button>Button 2</button>
</div>
<div className="column">
<button>Button 3</button>
<button>Button 4</button>
</div>
</div>

We then add our <CurvyLine> component and pass the refs it needs:

// prepare the refs
const button1Ref = useRef(null)
const button4Ref = useRef(null)
const canvasRef = useRef(null)
// ...
;<div className="grid-2-cols">
<svg className="absolute-svg" ref={canvasRef}>
<CurvyLine
sourceRef={button1Ref}
targetRef={button4Ref}
canvasRef={canvasRef}
/>
</svg>
<div className="column">
<button ref={button1Ref}>Button 1</button>
<button>Button 2</button>
</div>
<div className="column">
<button>Button 3</button>
<button ref={button4Ref}>Button 4</button>
</div>
</div>

useRef creates a piece of mutable state and passing it to a DOM element as ref={} creates a reference to that node. We can use that to call any DOM-level APIs – getBoundingClientRect in our case.

But nothing happens πŸ€”

Stubbornly, our curvy line doesn't show up.

That's because we're 1 render behind. CodeSandbox re-renders on code changes so you can make the line show up by poking the code.

Another way to show you we're behind is to add a piece of state that re-renders when you click a button.

const [counter, setCounter] = useState(0)
function clicked() {
setCounter(counter + 1)
}
// ...
;<button ref={button1Ref} onClick={clicked}>
Click me
</button>

Click the button and trigger a re-render. We don't even use the state anywhere!

SVG rendering is 1 step behind

Try it yourself πŸ‘‡

Dynamic elements make the situation worse. Sometimes rendered, sometimes not.

Button is hiding, line is delayed

Look at that SVG curve staying 1 render behind!

That's because React can't know the element a ref is going to represent until a rendering pass is complete. And when the pass is complete, React doesn't go back to re-render your component that depends on a ref.

useEffect is a common attempt to solve this:

const ref = useRef(null)
useEffect(() => {
if (ref.current) {
// measure and save into state
}
}, [ref])

But that doesn't work. The effect runs too late and you may end up with stale measurements in your state.

Make it perfect with useLayoutEffect

The perfect solution I've found is to combine useLayoutEffect and a dirty forced re-render. Dirty because it feels wrong.

const [forceRender, setForceRender] = useState(0)
useLayoutEffect(() => {
setForceRender(forceRender + 1)
}, [ref])

Like the button before, we force a re-render by updating state. Except now that happens when the ref updates instead of when a user clicks something.

And because it's a useLayoutEffect, the update runs synchronously during initial render. You get zero delay:

The curvy line draws beautifully

We put the trick in <CurvyLine> to make the component more self-contained and easier to use.

const CurvyLine = ({ sourceRef, targetRef, canvasRef }) => {
const sourceRect = sourceRef?.current?.getBoundingClientRect();
const targetRect = targetRef?.current?.getBoundingClientRect();
const canvasRect = canvasRef?.current?.getBoundingClientRect();
// force a 2nd-pass re-render when refs become available
const [forceRender, setForceRender] = useState(0);
useLayoutEffect(() => {
setForceRender(forceRender + 1);
}, [sourceRef, targetRef, canvasRef]);
if (!sourceRect || !targetRect || !canvasRect) {
// avoid render when refs undefined
return null;
}
// ...

React doesn't know this component changed when you un-render a button because refs are stable. You have to prompt it with a conditional rendering:

{
showButton1 && (
<CurvyLine
sourceRef={button1Ref}
targetRef={button4Ref}
canvasRef={canvasRef}
/>
)
}

Now you may think this is silly – why did we use useLayoutEffect, if we're un-rendering the line anyway? Because without the effect you run into the 1-step-behind issue and never render the line.

Conclusion

You can use refs and getBoundingClientRect to adjust your drawing to responsive layouts and dynamically positioned elements. Use a forced 2nd pass re-render to ensure it stays in sync.

It's not stupid if it works ✌️

Cheers,
~Swizec

PS: to make your SVG follow responsive layout changes live as you resize the screen (only designers and developers do that), you'll have to trigger a re-render when screen size changes

About the Author

Hi, I’m Swizec Teller. I help coders become software engineers.

Story time πŸ‘‡

React+D3 started as a bet in April 2015. A friend wanted to learn React and challenged me to publish a book. A month later React+D3 launched with 79 pages of hard earned knowledge.

In April 2016 it became React+D3 ES6. 117 pages and growing beyond a single big project it was a huge success. I kept going, started live streaming, and publishing videos on YouTube.

In 2017, after 10 months of work, React + D3v4 became the best book I'd ever written. At 249 pages, many examples, and code to play with it was designed like a step-by-step course. But I felt something was missing.

So in late 2018 I rebuilt the entire thing as React for Data Visualization β€” a proper video course. Designed for busy people with real lives like you. Over 8 hours of video material, split into chunks no longer than 5 minutes, a bunch of new chapters, and techniques I discovered along the way.

React for Data Visualization is the best way to learn how to build scalable dataviz components your whole team can understand.

Some of my work has been featured in πŸ‘‡

Created by Swizec with ❀️