Search code examples
reactjsp5.js

p5.js and React: how to update a sketch inside a component when one of the component's props is updated


I’m working on a project that integrates p5.js and React. My project consists of App.js and two child components: View1.js and View2.js. I want to pass information from View1 to View2 and have that changing information reflected in the sketch of View 2. My problem is that although I can pass data from View 1 to View 2, I don’t know how to update the sketch with the new value.

I think part of the problem might be caused by the fact that the sketch within the View 2 component is in instance mode, so once it’s initialized, it doesn’t change. Essentially, I’m looking for a way to reinitialize/refresh the sketch inside a component every time one of the component’s props changes in value so that the sketch is using the most recent value. I know p5-react-wrapper does have a way of dealing with this problem, but ideally, I'd like to do this with just the p5 library.

Does anyone know how to do that? I've included an example of what I want to do below (although it's a bit more complicated than this with my actual project).

App.js

import React, { useState } from 'react';
import View1 from "./components/View1.js";
import View2 from './components/View2.js';

function App() {

  const [data, setData] = useState(0);

  const firstViewToParent = (num) => {setData(num);}

  return (
    <div className="App">
      <View1
        firstViewToParent={firstViewToParent}
      />
      <View2
        dataFromSibling={data}
      />

    </div>
  );
}

export default App;

View1.js

import React, { useEffect } from "react";
import p5 from 'p5';

const View1 = props => {

    const Sketch = (p) => {

        p.setup = () => {
            p.createCanvas(400, 400);
        }

        p.draw = () => {
            p.background(0);
        }

        p.mouseClicked = () => {
            // Passing information from the child up to the parent
            props.firstViewToParent(p.mouseX);

        }
    }

    useEffect(() => {
        new p5(Sketch);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    return (
        <></>
    );
}

export default View1;

View2.js

import React, { useEffect } from "react";
import p5 from 'p5';

const View2 = props => {

    const Sketch = (p) => {

        p.setup = () => {
            p.createCanvas(400, 400);
        }

        p.draw = () => {
            p.background(255, 0, 0);
            // Want to be able to access updated information from sibling 
           // component inside the sketch
            p.print(props.dataFromSibling);
        }
    }

    useEffect(() => {
        new p5(Sketch);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    return (
        <></>
    );
}

export default View2;

Solution

    1. Your "View" components need to render container elements where the p5.js canvas should be inserted.
    2. Because you were passing empty arrays to the useEffect function calls, your View2 effect was not getting updated.
    3. The props object that is passed to your component function is never mutated. When props change a new props object is passed to a new call to your component function.
    4. When effects need to be updated their cleanup function is called, and then the initialization function is called again. Therefor it is important to remove the sketch during cleanup. In fact you should always do this, because if your entire component is removed you want to let p5.js know that before its canvas element is removed from the DOM.

    Here's a working example:

    // This is in place of an import statement
    const { useRef, useState, useEffect } = React;
    
    const View1 = props => {
      const containerRef = useRef();
      
      const Sketch = (p) => {
        p.setup = () => {
          p.print("View1 Initializing");
          p.createCanvas(200, 100);
        }
    
        p.draw = () => {
          p.background(150);
        }
    
        p.mouseClicked = () => {
          p.print('click!');
          // Passing information from the child up to the parent
          props.firstViewToParent(p.map(p.mouseX, 0, p.width, 0, 255));
        }
      }
    
      useEffect(
        () => {
          // make sure the p5.js canvas is a child of the component in the DOM
          new p5(Sketch, containerRef.current);
        },
        // This empty list tells React that this effect never needs to get re-rendered!
        []
      );
    
      // Note: you're going to want to defined an element to contain the p5.js canvas
      return (
        <div ref={containerRef}></div>
      );
    }
    
    const View2 = props => {
      console.log('view2');
      const containerRef = useRef();
      
      const Sketch = (p) => {
        p.setup = () => {
          p.print("View2 Initializing");
          p.createCanvas(200, 100);
        }
    
        p.draw = () => {
          p.background(props.dataFromSibling);
        }
      }
    
      useEffect(
        () => {
          let inst = new p5(Sketch, containerRef.current);
          
          // Cleanup function! Without this the new p5.js sketches
          // generated with each click will just appear one after the other.
          return () => inst.remove();
        },
        // Let React know that this effect needs re-rendering when the dataFromSibling prop changes
        [props.dataFromSibling]
      );
    
      return (
        <div ref={containerRef}></div>
      );
    }
    
    function App() {
      const [data, setData] = useState(0);
    
      const firstViewToParent = (num) => {
        console.log('handling firstViewToParent callback');
        setData(num);
      };
    
      return (
        <div className="App">
          <View1 firstViewToParent={firstViewToParent}/>
          <View2 dataFromSibling={data} />
        </div>
      );
    }
    
    ReactDOM.render(
      <React.StrictMode>
        <App />
      </React.StrictMode>,
      document.getElementById('root')
    );
    .App {
      display: flex;
      flex-direction: row;
    }
    <html>
    
    <head>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.min.js"></script>
      <script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/p5.js"></script>
    </head>
    
    <body>
      <div id="root"></div>
    </body>
    
    </html>

    Addendum: Update Without Removal

    So I was curious: What if you wanted to update an existing p5.js sketch that was created with useEffect() without removing and recreating the entire p5.js sketch. Well, disclaimer: I am not a ReactJS expert, but I think the answer is yes. Basically you need to do the following:

    1. Create a new state variable/set method to store and set the sketch itself.
    2. In the useEffect call that creates the sketch:
      1. Only create the sketch if it doesn't exist yet
      2. When you create the sketch, use the set method to store it in the state variable.
      3. Don't return a cleanup function
    3. Add another effect that is updated when the state variable that stores a reference the the sketch is updated
      1. This effect shouldn't create anything, it should just return a cleanup function
      2. This cleanup function should remove the sketch, if it exists (using the aforementioned state variable)
    4. In your sketch creation function, create a local variable to store the initial input data from props
    5. Add a function to your sketch object (by assigning p.updateFn = ...) that updates said local variable
    6. In your useEffect call that creates the sketch, if the sketch already exists, call said update function with the new value from props.

    Kind of complicated I know, but here's an example:

    // This is in place of an import statement
    const { useRef, useState, useEffect } = React;
    
    const View1 = props => {
      const containerRef = useRef();
      
      const Sketch = (p) => {
        p.setup = () => {
          p.print("View1 Initializing");
          p.createCanvas(200, 100);
        }
    
        p.draw = () => {
          p.background(150);
        }
    
        p.mouseClicked = (e) => {
          // It turns out that p5.js listens for clicks anywhere on the page!
          if (e.target.parentElement === containerRef.current) {
            p.print('click!');
            // Passing information from the child up to the parent
            props.firstViewToParent(p.map(p.mouseX, 0, p.width, 0, 255));
          }
        }
      }
    
      useEffect(
        () => {
          // make sure the p5.js canvas is a child of the component in the DOM
          let sketch = new p5(Sketch, containerRef.current);
          
          return () => sketch.remove();
        },
        // This empty list tells React that this effect never needs to get re-rendered!
        []
      );
    
      // Note: you're going to want to defined an element to contain the p5.js canvas
      return (
        <div ref={containerRef}></div>
      );
    }
    
    const View2 = props => {
      console.log('view2');
      const containerRef = useRef();
      const [sketch, setSketch] = useState(undefined);
      
      const Sketch = (p) => {
        let bgColor = props.dataFromSibling;
        p.setup = () => {
          p.print("View2 Initializing");
          p.createCanvas(200, 100);
        }
    
        p.draw = () => {
          p.background(bgColor);
        }
        
        p.updateBackgroundColor = function(value) {
          bgColor = value;
        }
      }
    
      useEffect(
        () => {
          if (!sketch) {
            // Initialize sketch
            let inst = new p5(Sketch, containerRef.current);
          
            setSketch(inst);
          } else {
            // Update sketch
            sketch.updateBackgroundColor(props.dataFromSibling);
          }
          
          // We cannot return a cleanup function here, be cause it would run every time the dataFromSibling prop changed
        },
        // Let React know that this effect needs re-rendering when the dataFromSibling prop changes
        [props.dataFromSibling]
      );
      
      useEffect(
        () => {
          // This effect is only responsible for cleaning up after the previous one 😅
          return () => {
            if (sketch) {
              console.log('removing sketch!');
              // Removing p5.js sketch because the component is un-mounting
              sketch.remove();
            }
          };
        },
        // This effect needs to be re-initialized *after* the sketch gets created
        [sketch]
      );
    
      return (
        <div ref={containerRef}></div>
      );
    }
    
    function App() {
      const [data, setData] = useState(0);
      const [showSketch, setShowSketch] = useState(true);
    
      const firstViewToParent = (num) => {
        console.log('handling firstViewToParent callback');
        setData(num);
      };
    
      return (
        <div className="App">
          <View1 firstViewToParent={firstViewToParent}/>
          
          {showSketch && <View2 dataFromSibling={data} />}
          <button onClick={() => setShowSketch(showSketch ? false : true )}>
            Toggle
          </button>
        </div>
      );
    }
    
    ReactDOM.render(
      <React.StrictMode>
        <App />
      </React.StrictMode>,
      document.getElementById('root')
    );
    .App {
      display: flex;
      flex-direction: row;
    }
    <html>
    
    <head>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.min.js"></script>
      <script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/p5.js"></script>
    </head>
    
    <body>
      <div id="root"></div>
    </body>
    
    </html>

    If this violates any ReactJS best practices or will be broken in some scenarios hopefully somebody who knows React better than me will chime in.

    Addendum #2: useLayoutEffect

    I have recently learned that this sort of effect, which adds a new element to the DOM, should generally be initialized with useLayoutEffect instead of useEffect. This is because useEffect runs in parallel with the update to the DOM and does not block rendering, whereas useLayoutEffect does block rendering. So when you modify the DOM in useEffect (by telling p5.js to create a canvas element) it may result in objects in the page flickering or suddenly moving from where they are initially positioned. Once I have more time to test this out I will update the code examples above.