I'm new to React and trying to build a component using HTML Canvas that draws very simple simulated water ripples. I have my functions working when I manually click buttons but every time I try to make an animation loop I run into errors.
It seems like something that should be done with useEffect but it feels like there's an important react concept I'm not getting here.
I have an array of circle objects in a state variable:
const [circles, setCircles] = useState([])
I can click a button to add a circle to it.
const createRipple = (e) => {
const canvas = canvasRef.current;
if (!canvas) return; // Ensure canvas element exists
let x = Math.floor(Math.random() * (canvas.width + 1));
let y = Math.floor(Math.random() * (canvas.height + 1));
setCircles((currentCircles) => {
return [
...currentCircles,
{
x: x,
y: y,
radius: 1.0
}
]
})
}
The function picks random x/y coordinates for the circle and starts it with a radius of 1.
{
x: 41,
y: 30,
radius: 1
}
After this I want to loop through two functions alternating. One to draw all the circles in the state variable, and then the other to update the circles so that the radius grows after each animation frame. The larger the radius is, the more transparent the circle is drawn.
function drawRipples() {
const canvas = canvasRef.current;
if (!canvas) return; // Ensure canvas element exists
const context = canvas.getContext('2d');
let width = canvas.width;
let height = canvas.height;
let maxRadius = Math.sqrt(width * width + height * height) / 6;
let rippleSpeed = maxRadius / 1500;
context.clearRect(0, 0, width, height);
// paint each circle with its increased radius and transparency
circles.map(circle => {
context.beginPath();
context.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2);
let transparency = 1 - (circle.radius / maxRadius);
console.log(transparency);
context.strokeStyle = 'rgba(0, 150, 255, ' + transparency + ')';
context.stroke();
})
}
After drawing the current collection of circles, I want to update them by increasing each circle's radius by a small amount defined as rippleSpeed. Once the circle's radius is greater than the maxRadius amount, the circle is removed from the state variable and it should no longer be drawn. So every time the circles state is updated it looks like this:
function updateCircles() {
let width = 300;
let height = 200;
let maxRadius = Math.sqrt(width * width + height * height) / 6;
let rippleSpeed = maxRadius / 1500;
setCircles((currentCircles) => {
return currentCircles.map(circle => {
// increase circle radius on each update if less than max.
// otherwise remove from array.
if(circle.radius < maxRadius){
circle.radius += rippleSpeed;
return circle
}
})
})
}
If I manually click buttons in this order the code works:
createRipple
drawRipples
updateCircles
drawRipples
updateCircles
drawRipples
updateCircles
...
I can easily add new ripples too and then continue drawing and updating them manually. How can I programmatically say "draw the ripples and update the circles until there are no circles left in the array?"
Errors:
I've tried this:
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return; // Ensure canvas element exists
const render = () => {
if(circles.length > 0){
drawRipples()
updateCircles()
}
}
requestAnimationFrame(render)
}, [circles]);
This will work until it's time to remove a circle from the collection. Then it seems like the render function continues to try to draw the circles anyway and I get:
Uncaught TypeError: Cannot read properties of undefined (reading 'x')
At the line where I call circle.x in the drawing loop. Why is there an undefined circle in my map loop?
circles.map(circle => {
context.beginPath();
context.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2);
let transparency = 1 - (circle.radius / maxRadius);
console.log(transparency);
context.strokeStyle = 'rgba(0, 150, 255, ' + transparency + ')';
context.stroke();
})
And I get errors about the maximum depth being reached if I try to update circles from within the useEffect because technically circles will always be different since the variable is immutable and always overwritten. What is the correct way to do this please?
xof that is throwing the exception? Please edit to clarify the problem and include a complete minimal reproducible example of the relevant code you are working with, complete error messages and stacktraces, and debugging logs and details. This should help readers better understand what you are asking.setIntervalto run the update loop in, and to not rely onuseStatefor this use case, unless you have a specific need. You don't really need the re-rendering that theuseStatecan perform if you end up having a render functiondrawRipplesthat you can call manually when needed. If you continue withuseStatefor the ripples however, its a tricky process because of the other hooks that may be necessary and in what sequence to set them up. Remember that setting state is not a synchronous process, which means needing to rely onuseEffectproperlycircleobject. It is happening where I callmapto loop through all the circles in the state variable after the last circle has been removed from the collection when it has reached the max radius. There must be something asynchronous happening because if there are no circles in the collection that loop should not run at all, which is why I'm confused.setIntervaland it seemed like it didn't work properly. If you have any suggestions about how I would use that in a component I'd love to see. But the main point of this is I'm trying to learn how to do this the react way.