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.
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:
sketch
outside of the parent componentsketch
a refsketch
from the useEffect
dependency array in the useP5
hooksketch
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.