I'm trying to build a space invader game from scratch using React/React-Hook & HTML5 canvas.
So far i achieved to draw my ship on the canvas but i can't figure out how to access my states in the "requestAnimationFrame" function. I did succeed to access REFs but i don't want all my vars to be refs.
So far my code looks like this :
import React from 'react';
import {makeStyles} from '#material-ui/core/styles';
const spaceInvaderStyles = makeStyles((theme) => ({
canvas: {
display: 'block',
margin: 'auto',
imageRendering: 'optimizeSpeed',
imageRendering: '-moz-crisp-edges',
imageRendering: '-webkit-optimize-contrast',
imageRendering: 'optimize-contrast',
backgroundColor: 'black',
}
}))
// GAME CONSTANTS
const GAME = {
shape: {
w:'640px',
h:'640px',
},
shipRow: '600',
shipColor: 'rgba(0,252,0)',
spritesSrc: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD4AAADuCAYAAABh/7RrAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAM4SURBVHhe7ZhRctswDAWrHCH3P6OPkNTuqB0FwxEAEqTkvt0feyYUJCRvX5xsvwr4frK/7WJ7sr9dxsf+KgeLqyG7eLNURstqNhVlSNTV0HX87j5HyXpP1NXA8QyeT97M0etbeDMtRF0NHH93cDwIi6uR8uIvXi94vo1eXwFRVwPHz8g6Oft8BURdDV3HPb9ezHAsw4xnJOpq6C5+tb8V9OzQtXSkbEZY8cPAcTVYXI1QicwuM48ZZUfU1cDxI1c77VHhPFFXQ9fxHp+tY6OdUDHPzvAg6mrg+LuD40FYXI2mF5731qfRnsjOs+d7IOpq6Doe8bPCqRFmPCNRV0PX8f31FM+x0Q6YPb8FUVeDxdXoKg2vjEaZUWYWoq4Gi6tR8sltNnxyK4TF1Wi6c7XTHhXOE3U1dB3v8dk6NtoJFfPsDA+irgaOvzs4HoTF1ej6zGt7IeuXpXpeBKKuBo4fsc5ZrIPeeY/sPHu+B6Kuhq7jET+rHcxe751/4d3TQtTV0F0868Yd6dmhZOlI+ZxxxTcfx9VgcTWapTJaVrOpKEOiroau43f3OUrWe6KuBo5n8HzyZo5e38KbaSHqauD4u4PjQVhcjZAX2R7wfKue1wNRVwPHj3gOjjo8+/oIRF0NHD9jtnMrnLYQdTVYXI2ucptRNkdW3I+oq8HiajT/y+qVSXX5ZOfZ8y+yz0DU1cDxI9aX1pmVRJ7HnvEg6mrgeIaIcxkq5tkZHkRdDRx/d3A8CIur0fTi7t5nfW5B1NXA8TOs8xWOnbHifkRdDRw/stppjxnPQ9TVYHE1QiWxuuxW3I+oq8HianSVRnX5ZOc9j3/ub//xvOSxvw1B1NXA8TOsg6sZ7ZAWRF0NHD/iOW2dG+2A7Dx7vgeiroau46N+3oWs90RdDRw/Yn25ugciz2PPeBB1NXD8iOdU1qcs3v3s119kn4moq6Hr+P6aouXYEc+30esrIOpqsLga00ukxcf3549y+9oey5+DqKvB4mosKRVbZh4ryo6oq8HiarC4GrKLN39fZn/v3p3W5wKiroau4/+bz1GIuhosrgaLq8HiarC4GiyuBourIbv4n/9Fqf1N/rU9tt/3UY/ZXED3UQAAAABJRU5ErkJggg==',
sprites: {
ship: {
x: 0,
y: 204,
w: 61,
h: 237,
}
},
player: {
initialPos: {
x: 290,
y: 580,
}
}
}
const KEYS = {
left:81,
right:68,
down: 83,
up: 90,
arrowLeft: 37,
arrowRight: 39,
arrowDown: 40,
arrowUp: 38,
}
const SpaceInvader = (props) => {
const classes = spaceInvaderStyles();
const canvasRef = React.useRef();
const [cctx, setCctx] = React.useState(null);
const [sprites, setSprites] = React.useState(null);
const [player, setPlayer] = React.useState({
pos: GAME.player.initialPos.x,
})
// keys
const [currentKey, setCurrentKey] = React.useState(null);
//time
let lastTime = 0;
const [counter, setCounter] = React.useState(0);
React.useEffect(() => {
// INIT
// context
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
setCctx(context)
// sprites
loadSprites();
}, [])
// key handler
React.useEffect(() => {
window.addEventListener('keydown', (event) => handleUserKeyPress(event,true));
window.addEventListener('keyup', (event) => handleUserKeyPress(event,false))
return () => {
window.removeEventListener('keydown', (event) => handleUserKeyPress(event,true));
window.removeEventListener('keyup', (event) => handleUserKeyPress(event,false))
};
}, [cctx, sprites, player, currentKey])
React.useEffect(() => {
if(!cctx) return
animate();
}, [cctx])
React.useEffect(() => {
if(spritesAreLoaded()){
cctx.drawImage(sprites, GAME.sprites.ship.x, GAME.sprites.ship.y, GAME.sprites.ship.w, GAME.sprites.ship.h, GAME.player.initialPos.x, GAME.player.initialPos.y , GAME.sprites.ship.w, GAME.sprites.ship.h)
}
}, [sprites])
React.useEffect(() => {
console.log(counter)
}, [counter])
// utils
const clearCanvas = () => {
cctx.clearRect(0,0, 640, 640);
}
const saveCanvas = () => {
cctx.save();
}
const drawImage = (image, sx, sy, sw, dx, dy, sh, dw, dh) => {
cctx.drawImage(image, sx, sy, sw, dx, dy, sh, dw, dh);
}
const restore = () => {
cctx.restore();
}
const loadSprites = () => {
var spritesImg = new Image();
spritesImg.src = GAME.spritesSrc;
spritesImg.onload = function() {
// sprites are loaded at this point
setSprites(spritesImg);
}
}
const spritesAreLoaded = () => {
return sprites !== null;
}
const move = (direction) => {
// cctx, sprites and all others state vars are at default value here too
clearCanvas();
saveCanvas();
drawImage(sprites, GAME.sprites.ship.x, GAME.sprites.ship.y, GAME.sprites.ship.w, GAME.sprites.ship.h, player.pos + (10 * direction), GAME.player.initialPos.y , GAME.sprites.ship.w, GAME.sprites.ship.h);
restore();
setPlayer({...player, pos: player.pos + (10 * direction)});
}
const handleUserKeyPress = React.useCallback( (event, isDown) => {
event.preventDefault();
const {key, keyCode} = event;
setCurrentKey(isDown ? keyCode : null);
}, [cctx, sprites, player, currentKey])
const updatePlayer = () => {
// currentKey is at default value here...
const direction = currentKey === KEYS.left ? -1 : currentKey === KEYS.right ? 1 : null;
if(direction !== null) move(direction)
}
const animate = (time) => {
var now = window.performance.now();
var dt = now - lastTime;
if(dt > 100) {
lastTime = now;
updatePlayer();
};
requestAnimationFrame(animate);
}
return (
<canvas
className={classes.canvas}
ref={canvasRef}
width={GAME.shape.w}
height={GAME.shape.h}
/>
)
}
export default SpaceInvader;
I'm trying to access "currentKey" in the thread function but it always return "null" (the default state value),
I found on some topic that you need to bind a context to the animate function but i don't know how to do it with a functional component (with a class component i would do a .bind(this))
I'm pretty new at HTML5 canvas so I might not be able to see the problem here.
All tips are appreciated,
Thanks in advance !
Finally after a lot of testing I did that :
React.useEffect(() => {
console.log("use Effect");
if(!cctx) return
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
})
and it works...
If anyone have a cleaner solution
EDIT 1 : After some more testing, it might not be a viable solution, it causes too many re-render
Related
I am trying to create a custom hook to get user's current location. I am using react-native-geolocation-services.
It returns null for the first time.
However, when I try to re-run the app. The geo data shows again.
Is this issue happening in asyn data?
Am I wrongly implemented the usestate so that the data didn't show in the first time?
Map component
import {useCurrentLocation} from '../queries/getCurrentLocation';
const Map = () => {
const {coordinate, watchError} = useCurrentLocation();
console.log('data',coordinate)
return <View style={styles.container}><MapView /></View>;
};
Custome Hook
import React, {useRef, useState, useEffect } from 'react';
import Geolocation, {watchPosition} from 'react-native-geolocation-service';
import useLocationPermission from '../hooks/useLocationPermission';
export const useCurrentLocation = () => {
const [coordinate, setCoordinate] = useState(null);
const [watchError, setWatchError] = useState(null);
const watchId = useRef(null);
const {hasPermission, hasPermissionError} = useLocationPermission();
const startWatch = () => {
if (!hasPermission) return;
watchId.current = Geolocation.watchPosition(
position => {
const latitude = position.coords.latitude;
const longitude = position.coords.longitude;
const speed = position.coords.speed;
setCoordinate({latitude, longitude, speed});
},
error => {
setWatchError(error);
},
{
accuracy: {
android: 'high',
//TODO config to ios
//ios: 'best',
},
enableHighAccuracy: true,
distanceFilter: 0,
interval: 20000,
fastestInterval: 2000,
},
);
};
const stopWatch = () => {
if (watchId.current == null) return;
Geolocation.clearWatch(watchId.current);
watchId.current = null;
};
useEffect(() => {
if (hasPermission) {
getCurrentCoordinate(coordinate);
}
startWatch();
return () => {
stopWatch();
};
}, [coordinate]);
return {coordinate, watchError};
};
const getCurrentCoordinate = coordinate => {
Geolocation.getCurrentPosition(position => {
coordinate = position;
});
return coordinate;
};
I am working on drawing app project where I am using react.js and react-konva. Now I am trying to add multiple shape selection for resizing feature. I find a codesandbox example but when I am trying to run the code on my browser adding the code in my project, I am not getting expected output that is shown in codesandbox output. Only the last element is resizing, not the previous elements. and also cannot able to add cntrl and shift key button selection feature as well.
The example is - https://codesandbox.io/s/react-konva-multiple-selection-forked-wysowd?file=/src/index.js:6016-6045
I am sharing the code below of App.js:
import "./App.css";
import { Layer, Rect, Stage, Transformer } from "react-konva";
import { useEffect, useRef, useState } from "react";
import Rectangle from "./Rectangle";
import { v4 as uuidv4 } from 'uuid';
const initialRectangle = [
{
x: 10,
y: 10,
width: 100,
height: 100,
fill: "red",
id: uuidv4(),
},
{
x: 150,
y: 150,
width: 100,
height: 100,
fill: "green",
id: uuidv4(),
},
{
x: 300,
y: 300,
width: 100,
height: 100,
fill: "blue",
id: uuidv4(),
}
];
function App() {
const selectionRect = useRef(null);
const [rectangles, setRectangles] = useState(initialRectangle);
const [nodesArray, setNodes] = useState([]);
const trRef = useRef(null);
const [selectedId, selectShape] = useState(null);
const layerRef = useRef(null);
const Konva = window.Konva;
const selection = useRef({
visible: false,
x1: 0,
y1: 0,
x2: 0,
y2: 0,
});
const updateSelection = () => {
const node = selectionRect.current;
node.setAttrs({
visible: selection.current.visible,
x: Math.min(selection.current.x1, selection.current.x2),
y: Math.min(selection.current.y1, selection.current.y2),
width: Math.abs(selection.current.x1 - selection.current.x2),
height: Math.abs(selection.current.y1 - selection.current.y2),
fill: "rgba(0, 161, 255, 0.3)",
});
};
const onMouseDown = (e) => {
const isElement = e.target.findAncestor(".elements-container");
const isTransformer = e.target.findAncestor("Transformer");
if (isElement || isTransformer) {
return;
}
const pos = e.target.getStage().getPointerPosition();
selection.current.x1 = pos.x;
selection.current.y1 = pos.y;
selection.current.x2 = pos.x;
selection.current.y2 = pos.y;
selection.current.visible = true;
updateSelection();
};
const onMouseMove = (e) => {
if (!selection.current.visible) {
return;
}
const pos = e.target.getStage().getPointerPosition();
selection.current.x2 = pos.x;
selection.current.y2 = pos.y;
updateSelection();
};
const onMouseUp = (e) => {
if (!selection.current.visible) {
return;
}
const selBox = selectionRect.current.getClientRect();
const elements = [];
// console.log({layerRef});
layerRef.current.find(".rectangle").forEach((node) => {
const nodeBox = node.getClientRect();
if (Konva.Util.haveIntersection(selBox, nodeBox)) {
elements.push(node);
}
});
trRef.current.nodes(elements);
console.log({trRef})
selection.current.visible = false;
Konva.listenClickTap = false;
updateSelection();
};
const onClickTap = (e) => {
if (selection.current.visible) {
return;
}
let stage = e.target.getStage();
let layer = layerRef.current;
let tr = trRef.current;
if (e.target === stage) {
selectShape(null);
tr.nodes([]);
setNodes([]);
layer.batchDraw();
return;
}
if (!e.target.hasName("rectangle")) {
return;
}
const metaPressed = e.evt.ctrlKey || e.evt.shiftKey;
// console.log({nodesArray});
const isSelected = tr.nodes().indexOf(e.target) >= 0;
// console.log({isSelected})
// console.log(tr.nodes().indexOf(e.target));
// console.log(metaPressed, isSelected);
// if (!metaPressed && !isSelected) {
// selectShape(e.target.id());
// tr.nodes([e.target]);
// setNodes([e.target]);
// } else if (metaPressed && !isSelected) {
// selectShape(e.target.id());
// tr.nodes([...tr.nodes(), e.target]);
// setNodes([...nodesArray, e.target]);
// } else if (metaPressed && isSelected) {
// selectShape(null);
// const nodes = tr.nodes();
// console.log(nodes);
// const index = nodes.indexOf(e.target);
// nodes.splice(index, 1);
// setNodes(nodes);
// tr.nodes(nodes);
// }
if (!metaPressed && !isSelected) {
// if no key pressed and the node is not selected
// select just one
tr.nodes([e.target]);
} else if (metaPressed && isSelected) {
// if we pressed keys and node was selected
// we need to remove it from selection:
const nodes = tr.nodes().slice(); // use slice to have new copy of array
// remove node from array
nodes.splice(nodes.indexOf(e.target), 1);
tr.nodes(nodes);
} else if (metaPressed && !isSelected) {
// add the node into selection
const nodes = tr.nodes().concat([e.target]);
tr.nodes(nodes);
}
trRef.current.getLayer().batchDraw();
};
return (
<Stage
width={window.innerWidth}
height={window.innerHeight}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onClick={onClickTap}
>
<Layer ref={layerRef}>
{rectangles.map((rect, i) => (
<Rectangle
key={i}
shapeProps={rect}
isSelected={rect.id === selectedId}
onSelect={(e) => {
// if (e.current !== undefined) {
// let temp = nodesArray;
// if (!nodesArray.includes(e.current)) temp.push(e.current);
// setNodes(temp);
// trRef.current.nodes(nodesArray);
// trRef.current.getLayer().batchDraw();
// }
selectShape(rect.id);
}}
onChange={(newAttrs) => {
const copyOfRectangles = JSON.parse(JSON.stringify(rectangles));
const currentIndex = rectangles.findIndex(item => item.id === newAttrs.id);
copyOfRectangles[currentIndex] = newAttrs;
setRectangles(copyOfRectangles);
console.log(copyOfRectangles);
}}
/>
))}
<Transformer
ref={trRef}
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
/>
<Rect fill="blue" ref={selectionRect} />
</Layer>
</Stage>
);
}
export default App;
the below code is Rectangle component -
import React from 'react'
import { Rect } from 'react-konva';
const Rectangle = ({shapeProps, onSelect, onChange}) => {
const shapeRef = React.useRef(null);
return (
<Rect
{...shapeProps}
onClick={() => onSelect(shapeRef)}
// onDragStart={() => onChange(shapeRef)}
onDragEnd={(e) => onChange({
...shapeProps,
x: e.target.x(),
y: e.target.y(),
})}
ref={shapeRef}
onTap={() => onSelect(shapeRef)}
name="rectangle"
draggable
onTransformEnd={(e) => {
const node = shapeRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// we will reset it back
node.scaleX(1);
node.scaleY(1);
onChange({
...shapeProps,
x: node.x(),
y: node.y(),
// set minimal value
width: Math.max(5, node.width() * scaleX),
height: node.height() * scaleY
});
}
}
/>
)
}
export default Rectangle
What is the problem I don't understand.
I am using React with TypeScript, I am trying to draw a rectangle shape on the canvas, the shape is drawn on the canvas but after drawing it again it went into an infinite loop. Even I am passing firstClicks, lastClicks as the second argument in useEffect.
due to running indefinitely, my app keeps crashing after some time.
here is my code:
import { useEffect, useRef, useState } from 'react';
interface ICoordinates{
x: number
y: number
}
const Canvas = ({height, width}: ICanvas) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const divRef = useRef<HTMLDivElement>(null);
let [firstClicks, setFirstClicks] = useState<ICoordinates>();
let [lastClicks, setLastClicks] = useState<ICoordinates>();
useEffect(() => {
const canvas = canvasRef.current?.getContext('2d');
const context = canvasRef.current;
let mousedown = false;
function drawRectangle(){
if(canvas){
canvas.beginPath();
if(firstClicks && lastClicks){
canvas.rect(firstClicks.x, firstClicks.y, lastClicks.x-firstClicks.x, lastClicks.y-firstClicks.y);
}
canvas.fillStyle = 'rgba(100,100,100,0.5)';
canvas.fill();
canvas.strokeStyle = "#df4b26";
canvas.lineWidth = 1;
canvas.stroke();
}
};
function redraw(){
if(context){
context.width = context.width;
}
drawRectangle();
};
if(context){
context.addEventListener("mousedown", function (e) {
setFirstClicks({
x: e.offsetX,
y: e.offsetY
})
mousedown = true;
});
context.addEventListener("mousemove", function (e) {
if (mousedown) {
setLastClicks({
x: e.offsetX,
y: e.offsetY
})
redraw();
}
});
context.addEventListener("mouseup", function (e) {
mousedown = false;
setLastClicks({
x: e.offsetX,
y: e.offsetY
})
});
context.addEventListener("mouseleave", function () {
mousedown = false;
});
}
},[firstClicks, lastClicks])
return (
<div ref={divRef}>
<canvas className='canvas' ref={canvasRef}>
</canvas>
</div>
)
}
export default Canvas
Have you tried this without having [firstClicks, lastClicks] and instead, have an empty dependency array: [].
The dependency array means that this useEffect callback will run on the mounting and unmounting of the component and any change to the variables in the dependency array.
I see that you are using setLastClicks and setFirstClicks meaning once the this is executed, the useEffect hook will detect a change in firstClicks or lastClick and run the effect again causing an infinite loop.
You can double check this by adding a console.log just before the setLastClicks and setFirstClicks to see if it is changing the state on every run of the hook's callback.
Here is a working version:
import { useEffect, useRef } from "react";
interface ICoordinates {
x: number;
y: number;
}
const Canvas = ({ height, width }: { height: number; width: number }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
console.log("Rerunning");
const canvas = canvasRef.current?.getContext("2d");
const context = canvasRef.current;
let mousedown = false;
let firstClick: ICoordinates = { x: 0, y: 0 };
let lastClick: ICoordinates = { x: 0, y: 0 };
function drawRectangle(
firstClicks: ICoordinates,
lastClicks: ICoordinates
) {
if (canvas) {
canvas.beginPath();
if (firstClicks && lastClicks) {
canvas.rect(
firstClicks.x,
firstClicks.y,
lastClicks.x - firstClicks.x,
lastClicks.y - firstClicks.y
);
}
canvas.fillStyle = "rgba(100,100,100,0.5)";
canvas.fill();
canvas.strokeStyle = "#df4b26";
canvas.lineWidth = 1;
canvas.stroke();
}
}
if (context) {
context.addEventListener("mousedown", function (e) {
firstClick = {
x: e.offsetX,
y: e.offsetY,
};
mousedown = true;
});
context.addEventListener("mousemove", function (e) {
if (mousedown) {
lastClick = {
x: e.offsetX,
y: e.offsetY,
};
drawRectangle(firstClick, lastClick);
}
});
context.addEventListener("mouseup", function (e) {
mousedown = false;
lastClick = {
x: e.offsetX,
y: e.offsetY,
};
});
context.addEventListener("mouseleave", function () {
mousedown = false;
});
}
}, []);
return (
<div ref={divRef}>
<canvas className="canvas" ref={canvasRef}></canvas>
</div>
);
};
export default Canvas;
Drawing rectangles :D
I have a canvas component in react. I am using useEffect to get the canvas element. So i have defined all needed functions in useEffect, as you can see below
import { useEffect, useRef } from "react"
import * as blobs2Animate from "blobs/v2/animate"
export const CornerExpand = () => {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext("2d")
const animation = blobs2Animate.canvasPath()
const width = canvas.clientWidth * window.devicePixelRatio
const height = canvas.clientHeight * window.devicePixelRatio
canvas.width = width
canvas.height = height
const renderAnimation = () => {
if (!ctx) return
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = "#E0F2FE"
ctx.fill(animation.renderFrame())
requestAnimationFrame(renderAnimation)
}
requestAnimationFrame(renderAnimation)
const size = Math.min(width, height) * 1
const defaultOptions = () => ({
blobOptions: {
seed: Math.random(),
extraPoints: 36,
randomness: 0.7,
size,
},
canvasOptions: {
offsetX: -size / 2.2,
offsetY: -size / 2.2,
},
})
const loopAnimation = () => {
animation.transition({
duration: 4000,
timingFunction: "ease",
callback: loopAnimation,
...defaultOptions(),
})
}
animation.transition({
duration: 0,
callback: loopAnimation,
...defaultOptions(),
})
const fullscreen = () => {
const options = defaultOptions()
options.blobOptions.size = Math.max(width, height) * 1.6
options.blobOptions.randomness = 1.4
options.canvasOptions.offsetX = -size / 2
options.canvasOptions.offsetY = -size / 2
animation.transition({
duration: 2000,
timingFunction: "elasticEnd0",
...options,
})
}
}, [canvasRef])
return (
<div>
<canvas className="absolute top-0 left-0 h-full w-full" ref={canvasRef} />
</div>
)
}
export default CornerExpand
Everything works as well, but now I have a problem. I want to execute the fullscreen() function when a button is clicked in the parent component. Since I have defined the function in useEffect, I can't call it directly, isn't it? What can I do to solve this?
You can do it like this.
export const CornerExpand = React.forwardRef((props, ref) => {
//....
{
//...
const fullscreen = () => {
const options = defaultOptions()
options.blobOptions.size = Math.max(width, height) * 1.6
options.blobOptions.randomness = 1.4
options.canvasOptions.offsetX = -size / 2
options.canvasOptions.offsetY = -size / 2
animation.transition({
duration: 2000,
timingFunction: "elasticEnd0",
...options,
})
}
ref.current = fullscreen;
}, [canvasRef]);
You can wrap this comp with React.forwardRef. And call it from parent.
<CornerExpand ref={fullscreenCallbackRef} />
And then call like this
fullscreenCallbackRef.current()
i've got Tabs component, it has children Tab components. Upon mount it calculates meta data of Tabs and selected Tab. And then sets styles for tab indicator. For some reason function updateIndicatorState triggers several times in useEffect hook every time active tab changes, and it should trigger only once. Can somebody explain me what I'm doing wrong here? If I remove from deps of 2nd useEffect hook function itself and add a value prop as dep. It triggers correctly only once. But as far as I've read docs of react - I should not cheat useEffect dependency array and there are much better solutions to avoid that.
import React, { useRef, useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { defProperty } from 'helpers';
const Tabs = ({ children, value, orientation, onChange }) => {
console.log(value);
const indicatorRef = useRef(null);
const tabsRef = useRef(null);
const childrenWrapperRef = useRef(null);
const valueToIndex = new Map();
const vertical = orientation === 'vertical';
const start = vertical ? 'top' : 'left';
const size = vertical ? 'height' : 'width';
const [mounted, setMounted] = useState(false);
const [indicatorStyle, setIndicatorStyle] = useState({});
const [transition, setTransition] = useState('none');
const getTabsMeta = useCallback(() => {
console.log('getTabsMeta');
const tabsNode = tabsRef.current;
let tabsMeta;
if (tabsNode) {
const rect = tabsNode.getBoundingClientRect();
tabsMeta = {
clientWidth: tabsNode.clientWidth,
scrollLeft: tabsNode.scrollLeft,
scrollTop: tabsNode.scrollTop,
scrollWidth: tabsNode.scrollWidth,
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
};
}
let tabMeta;
if (tabsNode && value !== false) {
const wrapperChildren = childrenWrapperRef.current.children;
if (wrapperChildren.length > 0) {
const tab = wrapperChildren[valueToIndex.get(value)];
tabMeta = tab ? tab.getBoundingClientRect() : null;
}
}
return {
tabsMeta,
tabMeta,
};
}, [value, valueToIndex]);
const updateIndicatorState = useCallback(() => {
console.log('updateIndicatorState');
let _newIndicatorStyle;
const { tabsMeta, tabMeta } = getTabsMeta();
let startValue;
if (tabMeta && tabsMeta) {
if (vertical) {
startValue = tabMeta.top - tabsMeta.top + tabsMeta.scrollTop;
} else {
startValue = tabMeta.left - tabsMeta.left;
}
}
const newIndicatorStyle =
((_newIndicatorStyle = {}),
defProperty(_newIndicatorStyle, start, startValue),
defProperty(_newIndicatorStyle, size, tabMeta ? tabMeta[size] : 0),
_newIndicatorStyle);
if (isNaN(indicatorStyle[start]) || isNaN(indicatorStyle[size])) {
setIndicatorStyle(newIndicatorStyle);
} else {
const dStart = Math.abs(indicatorStyle[start] - newIndicatorStyle[start]);
const dSize = Math.abs(indicatorStyle[size] - newIndicatorStyle[size]);
if (dStart >= 1 || dSize >= 1) {
setIndicatorStyle(newIndicatorStyle);
if (transition === 'none') {
setTransition(`${[start]} 0.3s ease-in-out`);
}
}
}
}, [getTabsMeta, indicatorStyle, size, start, transition, vertical]);
useEffect(() => {
const timeout = setTimeout(() => {
setMounted(true);
}, 350);
return () => {
clearTimeout(timeout);
};
}, []);
useEffect(() => {
if (mounted) {
console.log('1st call mounted');
updateIndicatorState();
}
}, [mounted, updateIndicatorState]);
let childIndex = 0;
const childrenItems = React.Children.map(children, child => {
const childValue = child.props.value === undefined ? childIndex : child.props.value;
valueToIndex.set(childValue, childIndex);
const selected = childValue === value;
childIndex += 1;
return React.cloneElement(child, {
selected,
indicator: selected && !mounted,
value: childValue,
onChange,
});
});
const styles = {
[size]: `${indicatorStyle[size]}px`,
[start]: `${indicatorStyle[start]}px`,
transition,
};
console.log(styles);
return (
<>
{value !== 2 ? (
<div className={`tabs tabs--${orientation}`} ref={tabsRef}>
<span className="tab__indicator-wrapper">
<span className="tab__indicator" ref={indicatorRef} style={styles} />
</span>
<div className="tabs__wrapper" ref={childrenWrapperRef}>
{childrenItems}
</div>
</div>
) : null}
</>
);
};
Tabs.defaultProps = {
orientation: 'horizontal',
};
Tabs.propTypes = {
children: PropTypes.node.isRequired,
value: PropTypes.number.isRequired,
orientation: PropTypes.oneOf(['horizontal', 'vertical']),
onChange: PropTypes.func.isRequired,
};
export default Tabs;
useEffect(() => {
if (mounted) {
console.log('1st call mounted');
updateIndicatorState();
}
}, [mounted, updateIndicatorState]);
This effect will trigger whenever the value of mounted or updateIndicatorState changes.
const updateIndicatorState = useCallback(() => {
...
}, [getTabsMeta, indicatorStyle, size, start, transition, vertical]);
The value of updateIndicatorState will change if any of the values in its dep array change, namely getTabsMeta.
const getTabsMeta = useCallback(() => {
...
}, [value, valueToIndex]);
The value of getTabsMeta will change whenever value or valueToIndex changes. From what I'm gathering from your code, value is the value of the selected tab, and valueToIndex is a Map that is re-defined on every single render of this component. So I would expect the value of getTabsMeta to be redefined on every render as well, which will result in the useEffect containing updateIndicatorState to run on every render.