javascriptreactjshtml5-canvasuse-effectusecallback

Accessing and updating canvas node callback inside of useEffect - React


I've created a canvas which is set to a state using a callback. circles are then created based on mouse x and y that are then drawn to the canvas state after clearing the canvas for each frame. each draw frame reduces the radius of the circles until they fade away

Currently the canvas only updates inside of canvasDraw() on mousemove.

The problem: the canvas needs to be updated using an interval inisde of the useEffect() method so that the circles gradually reduce in radius over time.

for some reason retrieving the canvas state inside of useEffect() returns null. I think this may be becuase the useEffect interval is called once but before the ctx is able to be initialised to a state. But I dont know where to go from here...

The following link was useful in fixing some leaks in my code but still results in a empty state at useEffect(): Is it safe to use ref.current as useEffect's dependency when ref points to a DOM element?

import React, { useEffect, useState, useCallback, useReducer } from "react";

const Canvas = () => {

    const [isMounted, toggle] = useReducer((p) => !p, true);
    const [canvasRef, setCanvasRef] = useState();
    const [ctx, setCtx] = useState();

    const handleCanvas = useCallback((node) => {
        setCanvasRef(node);
        setCtx(node?.getContext("2d"));
    }, []);

    const [xy, setxy] = useState(0);
    const [canDraw, setCanDraw] = useState(false);
    const [bubbles, setBubbles] = useState([]);

    const canvasDraw = (e) => {
        setxy(e.nativeEvent.offsetX * e.nativeEvent.offsetY);
        xy % 10 == 0 ? setCanDraw(true): setCanDraw(false);
        canDraw && createBubble(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
        drawBubbles();
    };

    const createBubble = (x, y) => {
        const bubble = {
            x: x,
            y: y,
            radius: 10 + (Math.random() * (100 - 10)) 
        };
        setBubbles(bubbles => [...bubbles, bubble])
    }

    const drawBubbles = useCallback(() => {
        if (ctx != null){
            ctx.clearRect(0,0,canvasRef.width,canvasRef.height);
            bubbles.forEach((bubble) => {
                bubble.radius = Math.max(0, bubble.radius - (0.01 * bubble.radius));
                ctx.beginPath()
                ctx.arc(bubble.x, bubble.y, bubble.radius, 0, 2 * Math.PI, false)
                ctx.fillStyle = "#B4E4FF"
                ctx.fill()
            }, [])
        }
    });

    useEffect(() => {
        const interval = setInterval(() => {
            console.log(ctx);
            drawBubbles(ctx); // <------ THE PROBLEM (ctx is null)
        }, 100)
        return () => clearInterval(interval);
    }, []);

    return (
        <main className="pagecontainer">
            <section className="page">
                <h1>Bubbles!</h1>
                {isMounted && <canvas
                    onMouseMove={canvasDraw}
                    ref={handleCanvas}
                    width={`1280px`}
                    height={`720px`}
                />}
            </section>
        </main>
    );
}

export default Canvas

Solution

  • Something I learned the hard way with useEffect and useState is that you have to be very careful about what variables are actually being closed over every time the component rerenders.

    It's simpler to understand with an example:

    export const SimpleExample = () => {
        const [ fruit, setFruit ] = useState('apple')
    
        useEffect(() => {
            const interval = setInterval(() => {
                console.log(fruit)
            }, 1000)
    
            return () => clearInterval(interval)
        }, [])
    
        return (<div>
            <p>{fruit}</p>
            <button onClick={() => setFruit('orange')}>Make Orange</button>
            <p>The console will continue to print "apple".</p>
        </div>)
    }
    

    Here, we are printing the value of fruit every second. At first it is "apple", and there's a button to change it to "orange". But what really happens when the button is clicked?

    1. Before clicking the button, SimpleExample is run as a function, creating a variable called fruit and initializing it to "apple".
    2. The useEffect callback is invoked, setting up an interval that closes over the fruit variable. Every second, "apple" is printed to console.
    3. Now we click the button. This calls setFruit which will force the SimpleExample function to rerun.
    4. A new fruit variable is created, this time with a value of "orange". The useEffect is not invoked this time, as no dependencies have changed.
    5. Because the useEffect closed over the first fruit variable created, that's what it continues to print. It prints "apple", not "orange".

    The same thing is happening in your code, but with the ctx variable. The effect has closed over the ctx variable when it was null, so that's all it remembers.


    So how do we fix this?

    What you should do depends on the use case. For example, you could reinvoke the effect by supplying it a dependency on ctx. This turns out to be a good way to solve our fruit example.

    useEffect(() => {
        const interval = setInterval(() => {
            console.log(fruit)
        }, 1000)
    
        return () => clearInterval(interval)
    }, [fruit])
    

    In your particular case, however, we're dealing with a canvas. I'm not 100% sure on what the best practices are with canvas and React, but my intuition says that you don't necessarily want to rerender the canvas every time a bubble is added (recall: rerender happens anytime set* functions are called). Rather, you want to redraw the canvas. Refs happen to be useful for saving variables without triggering rerenders, giving you full control over what happens.

    import React, { useEffect, useRef } from "react";
    
    const Canvas = () => {
        const canvasRef = useRef()
        const bubbles = useRef([])
    
        const createBubble = (x, y) => {
            const bubble = {
                x: x,
                y: y,
                radius: 10 + (Math.random() * (100 - 10)) 
            };
            bubbles.current = [...bubbles.current, bubble]
        }
    
        const drawBubbles = () => {
            const ctx = canvasRef.current?.getContext('2d')
            if (ctx != null) {
                ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
                bubbles.current.forEach((bubble) => {
                    bubble.radius = Math.max(0, bubble.radius - (0.01 * bubble.radius));
                    ctx.beginPath()
                    ctx.arc(bubble.x, bubble.y, bubble.radius, 0, 2 * Math.PI, false)
                    ctx.fillStyle = "#B4E4FF"
                    ctx.fill()
                })
            }
        };
    
        const canvasDraw = (e) => {
            const canDraw = (e.nativeEvent.offsetX * e.nativeEvent.offsetY) % 10 == 0
            if (canDraw) createBubble(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
            drawBubbles();
        };
    
        useEffect(() => {
            const interval = setInterval(() => {
                drawBubbles(canvasRef.current?.getContext('2d'));
            }, 100)
            return () => clearInterval(interval);
        }, []);
    
        return (
            <main className="pagecontainer">
                <section className="page">
                    <h1>Bubbles!</h1>
                    <canvas
                        onMouseMove={canvasDraw}
                        ref={canvasRef}
                        width={`1280px`}
                        height={`720px`}
                    />
                </section>
            </main>
        );
    }
    
    export default Canvas
    

    In this case, there is only ever one single canvasRef and bubbles variable since they are defined as references. Therefore, the effect only closes over the one instance and always has access to the current value of the reference, even if it changes.