Spying at 60FPS

or, how I built a fast animating frontend in React for some *~spooky~* folks, without any animation libraries

Grab the source code!

About me

  • Software engineer at martech company
  • Occasional freelancer for projects where I try out bad ideas

About You

You'll get the most out of this talk if you:

  • Are at least comfortable with React
  • Have heard the word "animate" before

So why React?

Cons:
  • Virtual DOM means React can't get this performance out of the box
  • No library exists that is flexible enough for this use case

Winner: React

IF you have:
  • a complex UI (and only a small part is animation)
  • the animation can't be done in CSS

Animate in React in 3 easy steps

  • Get the data fast: Websockets
  • Loop fast: RequestAnimationFrame
  • Draw the data fast: HTML Canvas
  • Debugging tips

Live demo time

Get the source code

Websockets

For when you need to move data from the backend to the frontend with no latency

credit: Marshall Ruse

Websockets!

  • Open connection once, persist for life of the app
  • Reduces bytes transferred by ~80%
  • Don't think too hard about it, just use a library

Why it works

Quick note on websocket libraries

react-use-websocket
  • 1,300 stars on github
  • doesn't work for fast-refreshing applications

Animate in React in 3 easy steps

  • Get the data fast: Websockets
  • Loop fast: RequestAnimationFrame
  • Draw the data fast: HTML Canvas
  • Debugging tips

What's
requestAnimationFrame?

The rude way to animate

setInterval(()=>draw(),1000/60)
  • Doesn't take priority over other browser tasks (so can stutter or skip)
  • Is not guaranteed to run in the interval specified, it's just a best estimate
  • MIGHT keep chugging away even if the user abandons the tab

The polite way to animate

requestAnimationFrame(draw)
  • Synced to display refresh rate (magic??)
  • Will DEFINITELY automatically pause if user leaves

This is not a presentation about the event loop, but here's some (SHORT) info on the event loop

setTimeout/setInterval: macrotasks (last)
promise: microtask (first)
requestAnimationFrame: special microtask (first-er: cuts in line before microtasks if it's time to repaint)

Basic syntax for requestAnimationFrame

function draw() {
	//animation logic goes here
    requestAnimationFrame(draw);
  	}
requestAnimationFrame(draw);
						

requestAnimationFrame with React, for an animation that runs indefinitely

	
useEffect(() => {
	let animationFrameId;
	const draw = () => {
    //animation logic here
		animationFrameId = requestAnimationFrame(draw);
	};
    draw();
	return () => {
     	cancelAnimationFrame(animationFrameId);
   	};
},[]);
						

Animate in React in 3 easy steps

  • Get the data fast: Websockets
  • Loop fast: RequestAnimationFrame
  • Draw the data fast: HTML Canvas
  • Debugging tips

What's in the animate loop?

HTML Canvas

  • The backbone of drawing on the Web
  • Draw lines, shapes, text, images
  • Used in popular libraries like Phaser.js, p5.js

Point of order...

render

  • "put lines, shapes, etc onto a screen to create an animation"
const Canvas = ({props})=>{
	const canvasRef = useRef(null);
	const [context, setContext] = useState(null);

	useEffect(() => {
    	if (canvasRef.current) {
      	setContext(canvasRef.current.getContext("2d"));
      	}
  	}, []);
	return <canvas ref={canvasRef}>;
}

useRef

  • an "escape hatch" for React to give direct DOM access
  • Often used for setting focus, scrolling into view, etc
  • Perfect for drawing on a <canvas> element also
const myPoints = [{x:10,y:220},{x:125,y:10},{x:250,y:220}];
	ctx.strokeStyle="lightblue"; 
	  ctx.beginPath();
	  ctx.lineTo(250,220);
for (const point of myPoints){
	ctx.lineTo(point.x,point.y);
}
ctx.stroke();
let myPoints = [{x:10,y:220},{x:125,y:10},{x:250,y:220}]
const draw = ()=>{
	ctx.clearRect(0,0,300,300);
	const offsetX = 1*Math.random()>0.5?-1:1;
	const offsetY = 1*Math.random()>0.5?-1:1;
	ctx.beginPath();
	for (const point of myPoints){
		point.x+=offsetX;
		point.y+=offsetY;
	ctx.lineTo(point.x,point.y);
	}
	ctx.stroke();
	animationFrameId = requestAnimationFrame(draw);
}
let offsetX =1;
let offsetY = 1;
const draw = ()=>{
	ctx.clearRect(0,0,300,300);
	const myPoints = [{x:10,y:150},{x:85,y:10},{x:175,y:150}]
	ctx.beginPath();
	for (const point of myPoints){
		point.x+=offsetX;
		point.y+=offsetY;
	ctx.lineTo(point.x,point.y);
	if (point.x<=0 ||point.x>=300){
        offsetX*=-1;
    }
    if (point.y<=0||point.y>=300){
        offsetY*=-1;
    }
	}
	ctx.stroke();
	animationFrameId = requestAnimationFrame(draw);
}

https://dev.to/georgekobaidze/backend-engineer-me-ships-a-browser-game-with-one-unintentional-system-requirement-my-monitor-1ojd

const draw = () => { 
   xOffset+=0.5*offset;
   ctx.clearRect(0,0,300,300)
   //draw the face: not shown for brevity
   //draw the nose:
   ctx.beginPath();
   ctx.lineWidth=8;
   ctx.lineCap="round";
   ctx.moveTo(75, 75)
   ctx.lineTo(75+xOffset,75)
   ctx.stroke();
   ctx.closePath();
   if (xOffset>100 || xOffset<0 ){
	  offset*=-1;
   }
}
const firstFrame = ()=>{
	//draw the face (not shown), then kick off the RAF
	requestAnimationFrame(draw);
}
const draw = () => { 
   xOffset+=0.5*offset;
   ctx.clearRect(70,70,xOffset,10) //clears a box starting
   //at coordinate (70,70) of size (xOffset,10)
   //draw the nose:
   ctx.beginPath();
   ctx.lineWidth=8;
   ctx.lineCap="round";
   ctx.moveTo(75, 75)
   ctx.lineTo(75+xOffset,75)
   ctx.stroke();
   ctx.closePath();
   if (xOffset>100 || xOffset< 0 ){
	  offset*=-1;
   }
   firstFrame()
}


  useEffect(() => {
    const draw = () => {
      if (facesRef.current) {
        context.clearRect(0, 0, canvasWidth, canvasHeight);
        context.beginPath();
			for (const face_point of facesRef.current) {
            context.lineTo(
              face_point.x,
              face_point.y,
            );
            context.stroke();
          }
        }
      }
    };
    let animationFrameId;
      const renderAndRequestNewFrame = () => {
        draw();
        sendMessage("{'sendNext':'frame'}");
        animationFrameId = requestAnimationFrame(renderAndRequestNewFrame);
      };
      renderAndRequestNewFrame();
    return () => {
      cancelAnimationFrame(animationFrameId);
  }, [facesRef, context, sendMessage]);

Animate in React in 3 easy steps

  • Get the data fast: Websockets
  • Loop fast: RequestAnimationFrame
  • Draw the data fast: HTML Canvas
  • Surprise extra tips: prevent rerenders and work with, not against, JS
  • Debugging tips

Memoize This Component

What is React.Memo?

  • Without memo: When parent rerenders, children re-render
  • With memo: children only re-render when their props change (React does shallow prop comparison tho)
  • Replacement for componentShouldUpdate in the days of class components
  • Don't overuse: React now has to calculate whether to re-render the component
  • React 19+ with React Compiler might handle this for you?
Demo

Basic syntax for Memo

const MyComponent = ({props})=>{
						/*some very cool stuff here*/
						return (<>Very Cool Component {props.name}<>)
			}

export default React.memo(MyComponent);
						

Work with, not against, Javascript

When Loop Performance Matters


myArray.forEach(item=>doSomethingWith(item));
//is SIGNIFICANTLY slower than
for (const item of myArray){
	doSomethingWith(item)
}
		

Some benchmarks put for..of as more than twice as fast as .forEach.

(I tried my own benchmark and forEach crashed my computer 🤷‍♀️)

Animate in React in 3 easy steps

  • Get the data fast: Websockets
  • Loop fast: RequestAnimationFrame
  • Draw the data fast: HTML Canvas
  • Surprise extra tips: prevent rerenders and work with, not against, JS
  • Debugging tips

Useful debugging tools

Chrome devtools

Chrome's FPS palette (ctrl-shift-p)

If all else fails: console.time() and console.timeEnd()

How to use .time() and .timeEnd()

console.time("my expensive operation")
//do something expensive
console.timeEnd("my expensive operation")
produces...
my expensive operation: 15801ms - timer ended

Mission Accomplished

Thank you

Contact me