Search code examples
javascriptreactjsp5.js

React rendering two p5.js canvases


I'm trying to use p5.js with react. For some reason, react keeps rendering two canvases. I know this can be solved by turning off StrictMode, but I want to keep the benefits of strict mode while I work on this project. I have tried using a cleanup function, but that doesn't seem to be working either. I'm only using the p5 library, but this still happens even if I use the react-p5 library. Here is my code below.

useP5.js

import p5 from "p5";
import { useEffect, useRef } from "react";

function useP5(sketch, dep) {
  const p5ref = useRef();

  useEffect(() => {
    const instance = new p5(sketch, p5ref.current);
    return instance.remove;
  }, [sketch, dep]);

  return p5ref;
}

export { useP5 };

App.js

import { useState } from "react";
import { useP5 } from "./hooks/useP5";

function App() {
  const sketch = (p) => {
    p.setup = () => {
      p.createCanvas(400, 400);
    };

    p.draw = () => {
      p.background(200);
      p.ellipse(50, 50, 50, 50);
    };
  };

  const p5Ref = useP5(sketch);

  return (
    <>
      <div ref={p5Ref}></div>
    </>
  );
}

export default App;

I also included the package.json.

{
  "name": "unending-boundless-dream",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "p5": "^1.7.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "web-vitals": "^2.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

The weirdest thing is that I created a stackblitz to replicate the problem. But the stackblitz...doesn't do that. Instead, it only show one canvas.

https://stackblitz.com/edit/stackblitz-starters-cafqv9?file=package.json

This is what my result looks like unless I turn off StrictMode. Double rendering with react and p5


Solution

  • sketch is part of the useP5 hooks' useEffect dependency array, meaning you'll make a new instance of p5 whenever sketch changes.

    Because sketch is defined as a plain function within the parent component, whenever the parent component is rendered, sketch changes and the useP5 useEffect will run.

    Now, you do have what appears to be a correct cleanup routine for the useEffect. I'm not sure exactly why the cleanup isn't working in certain environments (I can only reproduce this locally using your package.json, and strict mode seems irrelevant; only the forced re-render triggers it for me locally in FF and Chromium). It seems like the the useEffect's cleanup should remove the initial p5 instance.

    Possible workarounds:

    • Move sketch outside of the parent component
    • Make sketch a ref
    • Remove sketch from the useEffect dependency array in the useP5 hook
    • Memoize sketch with useCallback: const sketch = useCallback(p => { ... }, []);.

    Here's the original browser test code, for posterity:

    const {StrictMode, useEffect, useRef, useState} = React;
    
    const useP5 = sketch => {
      const p5ref = useRef();
    
      useEffect(() => {
        const instance = new p5(sketch, p5ref.current);
        return () => {
          console.log("cleaning up...");
          
          // comment this out to get 2 canvases and 2 draw() loops
          instance.remove();
        };
      }, [sketch]);
    
      return p5ref;
    };
    
    const App = () => {
      const sketch = p => {
        p.setup = () => {
          p.frameRate(1);
          p.createCanvas(400, 400);
        };
        p.draw = () => {
          p.print(p.frameCount);
          p.background(200);
          p.ellipse(50, 50, 50, 50);
        };
      };
    
      ////// Force a second render //////
      const [test, setTest] = useState();
      useEffect(() => {
        setTest("force a second render");
      }, []);
      ///////////////////////////////////
      console.log("rendered");
    
      const p5Ref = useP5(sketch);
      return (
        <StrictMode>
          <div ref={p5Ref}></div>
        </StrictMode>
      );
    };
    
    ReactDOM.createRoot(
      document.querySelector("#app")
    ).render(<App />);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.7.0/p5.js"></script>
    <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="app"></div>

    And here's my local test code (in src/index.js), with <div id="app"></div> in public/index.html and your exact package.json:

    import p5 from "p5";
    import {StrictMode, useEffect, useRef, useState} from "react";
    import ReactDOM from "react-dom/client";
    
    const useP5 = sketch => {
      const p5ref = useRef();
    
      useEffect(() => {
        const instance = new p5(sketch, p5ref.current);
        return () => {
          console.log("cleaning up...");
          
          // comment this out to get 2 canvases and 2 draw() loops
          instance.remove();
        };
      }, [sketch]);
    
      return p5ref;
    };
    
    const App = () => {
      const sketch = p => {
        p.setup = () => {
          p.frameRate(1);
          p.createCanvas(400, 400);
        };
        p.draw = () => {
          p.print(p.frameCount);
          p.background(200);
          p.ellipse(50, 50, 50, 50);
        };
      };
    
      ////// Force a second render //////
      const [test, setTest] = useState();
      useEffect(() => {
        setTest("force a second render");
      }, []);
      ///////////////////////////////////
      console.log("rendered");
    
      const p5Ref = useP5(sketch);
      return (
        <StrictMode>
          <div ref={p5Ref}></div>
        </StrictMode>
      );
    };
    
    ReactDOM.createRoot(
      document.querySelector("#app")
    ).render(<App />);
    

    Looking into it a bit further, it appears that there's a race condition in .remove(). If you wrap the state set in the above failing code with a next tick timeout, or wrap the .remove() call in a timeout, it seems to clean up as expected:

    // works, surprisingly
    setTimeout(() => {
      setTest("force a second render");
    }, 0);
    
    // also works, surprisingly
    return () => {
      console.log("cleaning up...");
      setTimeout(() => instance.remove(), 0);
    };
    

    I'm sure there's an explanation for this behavior.