Search code examples
javascriptreactjscanvasrequestanimationframe

How to use this Javascript animation in React.js


I have the code below and I'd like to know the best way to make it work in React.js. My main issue is that I can't comprehend the object orientation principle in React. With JS is quite simple to make many instances of my Particle class. But how to do it React? Any help is very much appreciated!

const canvas = document.getElementById('canvas1')
const ctx = canvas.getContext('2d')
canvas.width = window.innerWidth
canvas.height = window.innerHeight


class Particle {
  constructor(x, y, size, weight) {
    this.x = x
    this.y = y
    this.size = size
    this.weight = weight
  }
  update() {
    if (this.y > canvas.height) {
      this.y = 0 - this.size
      this.x = Math.random() * 60 + 200
      this.weight = Math.random() * 0.5 + 1
    }
    this.y += this.weight
    //console.log('y is inside the canvas', this.y)
  }
  draw() {
    ctx.fillStyle = 'blue'
    ctx.beginPath()
    ctx.arc(this.x, this.y, this.size, 0, 2 * Math.PI)
    //ctx.fill()
    ctx.stroke()
  }
}
const particleArray = []
const numberOfBalls = 10

function createParticles() {
  for (let i = 0; i < numberOfBalls; i++) {
    const x = Math.random() * 60 + 200
    const y = Math.random() * canvas.height
    const size = Math.random() * 20 + 5
    const weight = Math.random() * 0.5 + 1
    particleArray.push(new Particle(x, y, size, weight))
  }
}
createParticles()

// animate canvas
function animate() {
  ctx.clearRect(0, 0, canvas.height, canvas.width)
  for (let i = 0; i < particleArray.length; i++) {
    particleArray[i].update()
    particleArray[i].draw()
  }
  requestAnimationFrame(animate)
}

animate()
window.addEventListener('resize', (e) => {
  canvas.width = window.innerWidth
  canvas.height = window.innerHeight
})
<canvas id="canvas1"></canvas>


Solution

  • Having classes and a fair amount of code (game, animation, etc) shouldn't require major changes to work with React.

    The critical thing is refactoring your code so that the canvas object can be passed to your animation/game code rather than as a global variable that React doesn't manage. Avoiding globals is good design even if you're not using React.

    Additionally, particleArray, numberOfBalls, createParticles and animate are loose in the global scope, but really, all work together to represent the animation and should be grouped in a class, closure, object, etc.

    After refactoring your animation code to accept a canvas parameter, your component renders the canvas element, passes it into the animation and keeps track of requestAnimationFrame so it can call cancelAnimationFrame on unmount.

    Keyboard and mouse events are also managed by React. You can use the normal handlers onKeyDown, onClick, onMouseMove, etc. Your game or animation manager would expose functions to respond to these events.

    You'll also want to handle window resizing in React. There's code in Rerender view on browser resize with React but I'll adapt it to hooks and use debouncing instead of throttling.

    Here's a quick, inelegant sketch with minimal adjustments to your code:

    const {useEffect, useRef, useState} = React;
    
    class Particle {
      constructor(canvas, x, y, size, weight) {
        this.canvas = canvas;
        this.ctx = canvas.getContext("2d");
        this.x = x;
        this.y = y;
        this.size = size;
        this.weight = weight;
      }
    
      update() {
        if (this.y > this.canvas.height) {
          this.y = 0 - this.size;
          this.x = Math.random() * 60 + 200;
          this.weight = Math.random() * 0.5 + 1;
        }
    
        this.y += this.weight;
      }
    
      draw() {
        const {x, y, size, ctx} = this;
        ctx.fillStyle = "blue";
        ctx.beginPath();
        ctx.arc(x, y, size, 0, 2 * Math.PI);
        ctx.stroke();
      }
    }
    
    const particleArray = [];
    const numberOfBalls = 10;
    
    function createParticles(canvas) {
      for (let i = 0; i < numberOfBalls; i++) {
        const x = Math.random() * 60 + 200;
        const y = Math.random() * canvas.height;
        const size = Math.random() * 20 + 5;
        const weight = Math.random() * 0.5 + 1;
        particleArray.push(
          new Particle(canvas, x, y, size, weight)
        );
      }
    }
    
    function animate(canvas) {
      const ctx = canvas.getContext("2d");
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    
      for (let i = 0; i < particleArray.length; i++) {
        particleArray[i].update();
        particleArray[i].draw();
      }
    }
    
    const Animation = () => {
      const canvasRef = useRef();
      const requestRef = useRef();
    
      const update = () => {
        animate(canvasRef.current);
        requestRef.current = requestAnimationFrame(update);
      };
    
      const handleResize = debounce(() => {
        canvasRef.current.width = innerWidth;
        canvasRef.current.height = innerHeight;
      }, 100);
    
      useEffect(() => {
        createParticles(canvasRef.current);
        handleResize();
        window.addEventListener("resize", handleResize);
        requestRef.current = requestAnimationFrame(update);
        return () => {
          cancelAnimationFrame(requestRef.current);
          window.removeEventListener("resize", handleResize);
        };
      }, []);
    
      return <canvas ref={canvasRef}></canvas>;
    };
    
    ReactDOM.createRoot(document.querySelector("#root"))
      .render(<Animation />);
    
    function debounce(fn, ms) {
      let timeout;
      return (...args) => {
        clearTimeout(timeout);
        timeout = setTimeout(() => fn(...args), ms);
      };
    }
    body { margin: 0; }
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="root"></div>

    This works, but the design isn't great because of the loose global functions and variables as mentioned earlier.

    From a React standpoint, the code could use custom hooks to handle the animation frame and window resizing to clean up the main component.

    As an aside, your clearRect call mixed up width and height parameters, so it will only clear the screen accurately on square screens.


    For completeness, here's a simple sketch that removes animation globals, shows handling events and moves logic to hooks. It should be clean when separated into modules.

    const {useEffect, useLayoutEffect, useRef, useState} = React;
    
    class Particle {
      constructor(ctx, x, y, r) {
        this.ctx = ctx;
        this.x = x;
        this.y = y;
        this.r = r;
        this.vx = 0;
        this.vy = 0;
      }
    
      moveTo(x, y) {
        this.x = x;
        this.y = y;
      }
    
      render() {
        this.ctx.beginPath();
        this.ctx.fillStyle = "white";
        this.ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
        this.ctx.fill();
      }
    }
    
    class SimpleAnimation {
      constructor(canvas) {
        this.canvas = canvas;
        this.ctx = canvas.getContext("2d");
        this.particles = [
          new Particle(this.ctx, canvas.width / 2, canvas.height / 2, 20)
        ];
      }
    
      onMouseMove(evt) {
        this.mouseX = evt.clientX;
        this.mouseY = evt.clientY;
      }
    
      tick() {
        this.particles.forEach(p => p.moveTo(this.mouseX, this.mouseY));
        this.ctx.fillStyle = "black";
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
        this.particles.forEach(p => p.render());
      }
    }
    
    const Animation = () => {
      const animationRef = useRef();
      const canvasRef = useRef();
    
      useAnimationFrame(() => animationRef.current.tick());
    
      const resize = () => {
        canvasRef.current.width = innerWidth;
        canvasRef.current.height = innerHeight;
      };
      useWindowResize(resize);
    
      useLayoutEffect(() => {
        resize();
        animationRef.current = new SimpleAnimation(canvasRef.current);
      }, []);
    
      return (
        <canvas 
          onMouseMove={evt =>
            animationRef.current.onMouseMove(evt)
          } 
          ref={canvasRef} 
        />
      );
    };
    
    ReactDOM.createRoot(document.querySelector("#root"))
      .render(<Animation />);
    
    function debounce(fn, ms) {
      let timeout;
      return (...args) => {
        clearTimeout(timeout);
        timeout = setTimeout(() => fn(...args), ms);
      };
    }
    
    function useWindowResize(fn, ms=100) {
      const handleResize = debounce(fn, ms);
      useEffect(() => {
        window.addEventListener("resize", handleResize);
        return () => {
          window.removeEventListener("resize", handleResize);
        };
      }, []);
    }
    
    function useAnimationFrame(fn) {
      const rafRef = useRef();
      
      const animate = time => {
        fn(time);
        rafRef.current = requestAnimationFrame(animate);
      };
      
      useEffect(() => {
        rafRef.current = requestAnimationFrame(animate);
        return () => cancelAnimationFrame(requestRef.current);
      }, []);
    }
    body { margin: 0; }
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="root"></div>