codesandbox.io/s/github/Tmcerlean/battleship
I am developing a simple board game and need to increment a state variable when a player clicks on a cell with a valid move.
The functionality for validating the move and making the move is all in place, however, I am having difficulty updating the state within the event listener.
I can see that the state is being updated when observed from a useEffect hook, but not when viewed from within the function (even following successive calls).
I have done some reading and believe it could have something to do with having a stale closure, but I am not certain.
My approach to solve this issue was to remove and then re-add the click event listener following every click by the user.
My assumption was that this would cause the correct (newly incremented) state variable to be picked up. Unfortunately, this does not appear to be the case and within the event listener function, the variable is never incremented from 0.
I initialise the state variable here:
const [placedShips, setPlacedShips] = useState(0);
Next, a click event listener is applied to each cell within the gameboard:
const clickListener = (e) => {
e.stopImmediatePropagation();
let direction = currentShip().direction;
let start = parseInt(e.target.id);
let end = start + currentShip().length - 1;
if (playerGameboard.checkValidCoordinates(direction, start, end)) {
playerGameboard.placeShip(placedShips, direction, start, end);
setPlacedShips((oldValue) => oldValue + 1);
console.log(placedShips);
}
};
const setEventListeners = () => {
const gameboardArray = Array.from(document.querySelectorAll(".cell"));
gameboardArray.forEach((cell) => {
cell.addEventListener("click", (e) => {
clickListener(e);
});
});
};
You will see that the setPlacedships state variable is incremented here and there is a console log to report its value.
I am aware that the useState hook is asynchronous and so console.log will show 0 for the first time it is called. Consequently, I have a useEffect hook deployed outside of the function which also contains a console.log to report the changed value of setPlacedShips:
useEffect(() => {
removeEventListeners();
setEventListeners();
console.log(placedShips)
}, [placedShips])
After every click the placedShips variable is incremented by 1 and then two functions are run:
const removeEventListeners = () => {
const gameboardArray = Array.from(document.querySelectorAll(".cell"));
gameboardArray.forEach((cell) => {
cell.removeEventListener("click", (e) => {
clickListener(e);
});
});
};
which is immediately followed by the original setEventListeners function:
const setEventListeners = () => {
const gameboardArray = Array.from(document.querySelectorAll(".cell"));
gameboardArray.forEach((cell) => {
cell.addEventListener("click", (e) => {
clickListener(e);
});
});
};
As mentioned above, the issue is that the console log within the setEventListeners function constantly remains at 0, while the console log within the useEffect hook increments as expected.
For reference, here is the full component I am working on currently:
import React, { useEffect, useState, useLayoutEffect } from "react";
import gameboardFactory from "../../factories/gameboardFactory";
import Table from "../Reusable/Table";
import "./GameboardSetup.css";
// -----------------------------------------------
//
// Desc: Gameboard setup phase of game
//
// -----------------------------------------------
let playerGameboard = gameboardFactory();
const GameboardSetup = () => {
const [humanSetupGrid, setHumanSetupGrid] = useState([]);
const [ships, _setShips] = useState([
{
name: "carrier",
length: 5,
direction: "horizontal",
},
{
name: "battleship",
length: 4,
direction: "horizontal",
},
{
name: "cruiser",
length: 3,
direction: "horizontal",
},
{
name: "submarine",
length: 3,
direction: "horizontal",
},
{
name: "destroyer",
length: 2,
direction: "horizontal",
},
]);
const [placedShips, setPlacedShips] = useState(0);
const createGrid = () => {
const cells = [];
for (let i = 0; i < 100; i++) {
cells.push(0);
}
};
const createUiGrid = () => {
const cells = [];
for (let i = 0; i < 100; i++) {
cells.push(i);
}
let counter = -1;
const result = cells.map((cell) => {
counter++;
return <div className="cell" id={counter} />;
});
setHumanSetupGrid(result);
};
const setUpPlayerGrid = () => {
// createGrid('grid');
createUiGrid();
};
const currentShip = () => {
return ships[placedShips];
};
const clickListener = (e) => {
e.stopImmediatePropagation();
let direction = currentShip().direction;
let start = parseInt(e.target.id);
let end = start + currentShip().length - 1;
if (playerGameboard.checkValidCoordinates(direction, start, end)) {
playerGameboard.placeShip(placedShips, direction, start, end);
setPlacedShips((oldValue) => oldValue + 1);
console.log(placedShips);
}
};
const setEventListeners = () => {
const gameboardArray = Array.from(document.querySelectorAll(".cell"));
gameboardArray.forEach((cell) => {
cell.addEventListener("click", (e) => {
clickListener(e);
});
cell.addEventListener("mouseover", (e) => {
e.stopImmediatePropagation();
let direction = currentShip().direction;
let start = parseInt(cell.id);
let end = start + currentShip().length - 1;
if (currentShip().direction === "horizontal") {
const newShip = [];
if (playerGameboard.checkValidCoordinates(direction, start, end)) {
for (let i = start; i <= end; i++) {
newShip.push(i);
}
newShip.forEach((cell) => {
gameboardArray[cell].classList.add("test");
});
}
} else {
const newShip = [];
if (playerGameboard.checkValidCoordinates(direction, start, end)) {
for (let i = start; i <= end; i += 10) {
newShip.push(i);
}
newShip.forEach((cell) => {
gameboardArray[cell].classList.add("test");
});
}
}
});
cell.addEventListener("mouseleave", (e) => {
e.stopImmediatePropagation();
let direction = currentShip().direction;
let start = parseInt(cell.id);
let end = start + currentShip().length - 1;
if (currentShip().direction === "horizontal") {
const newShip = [];
if (playerGameboard.checkValidCoordinates(direction, start, end)) {
for (let i = start; i <= end; i++) {
newShip.push(i);
}
newShip.forEach((cell) => {
gameboardArray[cell].classList.remove("test");
});
}
} else {
const newShip = [];
if (playerGameboard.checkValidCoordinates(direction, start, end)) {
for (let i = start; i <= end; i += 10) {
newShip.push(i);
}
newShip.forEach((cell) => {
gameboardArray[cell].classList.remove("test");
});
}
}
});
});
};
const removeEventListeners = () => {
const gameboardArray = Array.from(document.querySelectorAll(".cell"));
gameboardArray.forEach((cell) => {
cell.removeEventListener("click", (e) => {
clickListener(e);
});
});
};
useEffect(() => {
setUpPlayerGrid();
// setUpComputerGrid();
}, []);
useEffect(() => {
console.log(humanSetupGrid);
}, [humanSetupGrid]);
// Re-render the component to enable event listeners to be added to generated grid
useLayoutEffect(() => {
setEventListeners();
});
useEffect(() => {
removeEventListeners();
setEventListeners();
console.log(placedShips);
}, [placedShips]);
return (
<div className="setup-container">
<div className="setup-information">
<p className="setup-information__p">Add your ships!</p>
<button
className="setup-information__btn"
onClick={() => console.log(placedShips)}
>
Rotate
</button>
</div>
<div className="setup-grid">
<Table grid={humanSetupGrid} />
</div>
</div>
);
};
export default GameboardSetup;
I am quite confused what is happening here and have been stuck on this problem for a couple of days now - if anybody has any suggestions then they would be highly appreciated!
Thank you.
const removeEventListeners = () => {
const gameboardArray = Array.from(document.querySelectorAll(".cell"));
gameboardArray.forEach((cell) => {
cell.removeEventListener("click", (e) => {
clickListener(e);
});
});
};
The above code does not remove any event listeners, which is probably the reason why 0 is still being logged. You pass a new anonymous function to removeEventListener. Since the function is just created it will never remove any event listeners, because it is not registered as an event listener.
Two different functions that do the same are not equal, which is why the event listener is not removed.
const a = (e) => clickListener(e); // passed to addEventListener
const b = (e) => clickListener(e); // passed to removeEventListener
console.log(a == b); //=> false
To add and remove events you cannot use anonymous functions. You either have to use named functions, or store the function in a variable. Then register and remove the event listener using the function name or variable.
Since you only forward the event to the clickListener you can simply replace your event handler registration with:
cell.addEventListener("click", clickListener);
Then remove it using:
cell.removeEventListener("click", clickListener);
Note that this scenario could've been avoided if you passed your event handlers using a more React approach. Instead of using cell.addEventHandler(...) you could've passed the event on creation of this element. eg. <div className='cell' id={counter} onClick={clickListener} />
When working with React you should try to not manipulate the DOM manually. React Components have Synthetic Events, which means that you don't need to add event listeners the vanilla way.
Just add each synthetic event to the cell component with its corresponding handler.
You can do it in the createUiGrid function:
const createUiGrid = () => {
const cells = [];
for (let i = 0; i < 100; i++) {
cells.push(i);
}
let counter = -1;
const result = cells.map((cell) => {
counter++;
return <div className="cell" id={counter} onClick={onClickHandler} onMouseOut={onMouseOutHandler} onMouseOver={onMouseOverHandler} />;
});
setHumanSetupGrid(result);
};
And then just move the code you did on vanilla to each corresponding handler (be sure to remove all listener manipulation before testing).
I'm trying to update a React Component (B) that renders an SVG object passed from a parent Component (A).
B then uses getSVGDocument().?getElementById("groupID") and adds handling for events based on members of the SVG group.
A also passes in a prop that indicates mouseover in a separate menu.
Simplified code in B:
export function ComponentB(props: {
overviewSvg: string
highlightKey?: string
}) {
function getElems(): HTMLElement[] {
let innerElems = new Array<HTMLElement>()
const svgObj: HTMLObjectElement = document.getElementById(
"my_svg"
) as HTMLObjectElement
if (svgObj) {
const elemGroup = svgObj.getSVGDocument()?.getElementById("elemGroup")
if (elemGroup) {
for (let i = 0; i < elemGroup.children.length; i++) {
innerElems.push(elemGroup.children[i] as HTMLElement)
}
}
}
return innerElems
}
const elems = getElems() // Also tried variations with useState and useEffect, can't seem to get the right combination...
useEffect(() => {
console.log("effect called")
console.log(elems)
elems?.forEach((elem) => {
elem.onmousedown = () => toggleColor(elem)
})
}, [elems, props.overviewSvg])
useEffect(() => {
elems?.forEach((elem) => {
if (elem.id === props.highlightKey) {
setActive(elem)
} else {
setInactive(elem)
}
})
}, [elems, props.highlightKey, props.overviewSvg])
return (
<>
<object data={props.overviewSvg} id="my_svg" />
</>
)
}
I'm not sure what the appropriate parameters for the dependencies in useEffect should be, or if this is entirely the wrong approach?
The console shows that on loading, the array of elems is often empty, in which case the onmousedown loop of course doesn't add any functions.
After the parent sets the props.highlightKey, the second function is triggered and that then triggers the first effect and adds the mousedown functions.
What should I be changing to make the component correctly render before any parent highlightKey changes?
I was able to get the desired behaviour by using useRef and applying it to the DOM element, then adding an onload behaviour to that element.
// Get a ref for the <object> element
const svgObj = useRef<HTMLObjectElement>(null)
const [elemArr, setElems] = useState(Array<HTMLElement>())
if (svgObj.current) {
svgObj.current.onload = () => {
const elems = getElems()
elems.forEach((elem) => {
elem.onmousedown = () => toggleColor(elem)
})
setElems(elems)
}
}
useEffect(() => {
elemArr.forEach((elem) => {
if (elem.id === props.highlightKey) {
setActive(elem)
} else {
setInactive(elem)
}
})
}, [elemArr, props.highlightKey])
return (
<object ref={svgObj} data={props.overviewSVG} ... />
)
I have a component that will start counting down from 5 mins when a timestamp (obsTakenTime) is received via props. When the countdown gets <=0, I render ‘Overdue’. At this point I need to clear interval which I think I've done, the issues is when if, I refresh the page the obstimeleft should remain overdue but the countdown automatically starts from 59 mins because the value of nextObservationTime becomes 59min and this.state.obsTimeleft becomes undefined even thought the value of timestamp obsTakenTime is the same. I've looked at other similar threads on SO but I couldn't get mine to work. Any help is appreciated.
similar post - Clear interval in React class
Countdown component
export default class ObservationCountDown extends React.Component {
constructor(props) {
super(props);
this.state = {
obsTimeleft: undefined
};
this.countDown = this.countDown.bind(this);
this.startCounter = this.startCounter.bind(this);
this.countDownInterval = null;
}
countDown() {
const { obsTakenTime} = this.props; //when last obs was taken by the user in ms
const nextDueTimeForObs = moment(obsTakenTime).add(5, 'minutes');
const nextObservationTime = Number(nextDueTimeForObs.subtract(moment.now()).format('m'));
const timeToDisable = 2; // disable buttons time
this.setState({ obsTimeleft: nextObservationTime + ' min' }, () => {
if (nextObservationTime <= Number(timeToDisable)) {
this.props.disablePatientUpdate();
}
if (nextObservationTime <= 0) {
clearInterval(this.countDownInterval); // doesn't work here
this.setState({ obsTimeleft: 'Overdue' }, () => {
if(this.state.obsTimeleft === 'Overdue'){
clearInterval(this.countDownInterval); // doesn't work here
}
});
}
});
}
componentDidMount() {
this.startCounter();
}
startCounter() {
this.countDownInterval = setInterval(this.countDown, 1000);
}
componentDidUpdate(prevProps){
if(this.props.patient.timestamp !== prevProps.patient.timestamp){
this.startCountdown();
}
}
componentWillUnmount(){
clearInterval(this.countDownInterval);
}
render() {
const { obsTimeleft } = this.state;
return (
<>
{(obsTimeleft && obsTimeleft === 'Overdue') ?
<div className="text-danger">
<strong>{obsTimeleft}</strong>
</div> :
<div>
<strong>{.obsTimeleft}</strong>
</div>}
</>
);
}
}
another version of countDown() that I tried and didn't work
countDown() {
const { obsTakenTime } = this.props; // obs duration - when last obs was taken by the user in min
const nextDueTimeForObs = moment(obsTakenTime).add(2, 'minutes');
const nextObservationTime = Number(nextDueTimeForObs.subtract(moment.now()).format('m'));
console.log('nextObservationTime', nextObservationTime);
this.setState({ obsTimeleft: nextObservationTime + ' min' })
if (nextObservationTime <= 0) {
this.setState({ obsTimeleft: 'Overdue' }, () => {
if(this.state.obsTimeleft === 'Overdue') {
clearInterval(this.countDownInterval);
}
});
this.props.enablePatientUpdate();
this.props.resetPatient(patient);
}
}
in my app, I am able to get the values, distance and names of location from an array by using the fuctions below. However, I am unable to dispatch the values obtained from them in my redux store using the mapDispatchToProps,
which is for example
handleNavigation() {
this.props.navigation.navigate('LocationLists');
this.props.totalDistanceChange(this.totalDistance()).
}
<Button
onPress={this.handleNavigation.bind(this)}
/>
const mapDispatchToProps = (dispatch) => ({
totalDistanceChange: totalDistance=> {
dispatch(totalDistanceChange(totalDistance));
}
});
I keep getting cannot update during an existing state transition.
below are just my functions as I wanted to keep it as simple as possible, kindly correct where appropriate.
totalDistance = () => {
const { selectedItemObjects } = this.state;
const total = selectedItemObjects.reduce((result, { Distance }) => result += Distance, 0);
return total.toFixed(1);
}
totalValue = () => {
const { selectedItemObjects } = this.state;
const total = selectedItemObjects.reduce((result, { Value }) => result += Value, 0);
return total;
}
renderLocationText = () => {
const { selectedItemObjects } = this.state;
return selectedItemObjects.length ?
`${selectedItemObjects.map((item, i) => {
let label = `${item.name}, `;
if (i === selectedItemObjects.length - 2) label = `${item.name} and `;
if (i === selectedItemObjects.length - 1) label = `${item.name}.`;
return label;
}).join('')}`
:
null;
}
my question is how can i pass the values obtained to my redux store
fixed it by converting it to a string
handleNavigation() {
this.props.navigation.navigate('LocationLists');
this.props.totalDistanceChange(this.totalDistance().toString()).
}
I am receiving some data as props and on click I am trying to display next items from array. In render I'm calling {this.dropdown()} which triggers folowing and display data succedsfully:
dropdown = () => {
var dropdown = undefined
if(this.props.catList){
const cat = this.props.catList
const list = JSON.stringify(this.props.catList)
if(list.length > 0){
dropdown = <div><p onClick={() =>{this.subCat()}}>{cat.title}</p>{this.state.firstSubCat}</div>
}
}
return dropdown
}
Next, when I click on item, sub categories is displayed with no issues and generates place where to display state for next function {this.state['sub'+cat.id]} :
subCat = () => {
let subCat = []
this.props.catList.children.map(cat => {
subCat.push(<div key={cat.id}><p key={cat.id} onClick={() =>{this.searchSubCat(cat.id)}}>{cat.title}</p>{this.state['sub'+cat.id]}</div>)
})
this.setState({firstSubCat: subCat})
}
Next two function is for loop through rest of array to display next items on click. (Please note that I did not use it from beginning because first line of data is not objects but contains 'children' as array so now i can use these two functions):
find = (array, id) => {
// Loop the entries at this level
for (const entry of array) {
// If we found it, return it
if (entry.id === id) {
return entry;
}
// If not but there's a type array, recursively search it
if (Array.isArray(entry.type)) {
const found = find(entry.type, id);
if (found) {
// Recursive search found it, return it
return found;
}
}
}
return undefined
}
searchSubCat = (id) => {
let subCat = []
const children = this.find(this.props.catList.children, id)
children.children.map(cat => {
subCat.push(<div key={cat.id}><p key={cat.id} onClick={() =>{this.searchSubCat(cat.id)}}>{cat.title}</p>{this.state['sub'+cat.id]}</div>)
})
this.setState({['sub' + id]: subCat})
}
So far there is no errors poping up but in generated place state is not being displayed. When generating place ( with this: {this.state['sub'+cat.id]} ) where to display state I pass its id to next step to set state with same id so state should be displayed there but nothing. If anybody can see where is issue could please respond could help me here out would be great ! Thanks.
Full code for component as requested in comment:
import React, {Component} from 'react';
class SearchResult extends Component {
constructor( props ){
super( props );
this.state = {
}
}
find = (array, id) => {
// Loop the entries at this level
for (const entry of array) {
// If we found it, return it
if (entry.id === id) {
return entry;
}
// If not but there's a type array, recursively search it
if (Array.isArray(entry.type)) {
const found = find(entry.type, id);
if (found) {
// Recursive search found it, return it
return found;
}
}
}
return undefined
}
searchSubCat = (id) => {
let subCat = []
const subId = 'sub' + id
console.log(subId)
const children = this.find(this.props.catList.children, id)
children.children.map(cat => {
subCat.push(<div key={cat.id}><p key={cat.id} onClick={() =>{this.searchSubCat(cat.id)}}>{cat.title}</p>{this.state['sub'+cat.id]}</div>)
})
this.setState({['sub' + id]: subCat})
}
subCat = () => {
let subCat = []
this.props.catList.children.map(cat => {
subCat.push(<div key={cat.id}><p key={cat.id} onClick={() =>{this.searchSubCat(cat.id)}}>{cat.title}</p>{this.state['sub'+cat.id]}</div>)
console.log('sub--'+cat.id)
})
this.setState({firstSubCat: subCat})
}
dropdown = () => {
var dropdown = undefined
if(this.props.catList){
const cat = this.props.catList
const list = JSON.stringify(this.props.catList)
if(list.length > 0){
dropdown = <div><p onClick={() =>{this.subCat()}}>{cat.title}</p>{this.state.firstSubCat}</div>
}
}
return dropdown
}
render() {
return (
<div>
{this.dropdown()}
</div>
)
}
}
export default SearchResult;
UPDATED:
I receive array from server with Redux which I send to my first component where I use map() method to find 1st level of array and send it to component with its childrens as props(catList). Cant really copy and paste catList prop value here so here is array, how i pass it and IMG of console.log(this.props.catList) :
Array:
[{"id":1,"title":"Electronics","path":"Electronics","children":[{"id":2,"title":"Accessories","children":[{"id":6,"title":"Portable Power Banks","children":[]},{"id":7,"title":"Charging Cables","children":[]},{"id":9,"title":"Batteries","children":[{"id":10,"title":"Disposable","children":[]},{"id":19,"title":"Rechargable","children":[]}]}]},{"id":3,"title":"Computers","children":[{"id":4,"title":"Components","children":[{"id":5,"title":"Laptops","children":[]}]}]}]},{"id":2,"title":"Accessories","path":"Electronics->Accessories","children":[{"id":6,"title":"Portable Power Banks","children":[]},{"id":7,"title":"Charging Cables","children":[]},{"id":9,"title":"Batteries","children":[{"id":10,"title":"Disposable","children":[]},{"id":19,"title":"Rechargable","children":[]}]}]},{"id":6,"title":"Portable Power Banks","path":"Electronics->Accessories->Portable Power Banks","children":null},{"id":7,"title":"Charging Cables","path":"Electronics->Accessories->Charging Cables","children":null},{"id":9,"title":"Batteries","path":"Electronics->Accessories->Batteries","children":[{"id":10,"title":"Disposable","children":[]},{"id":19,"title":"Rechargable","children":[]}]},{"id":10,"title":"Disposable","path":"Electronics->Accessories->Batteries->Disposable","children":null},{"id":19,"title":"Rechargable","path":"Electronics->Accessories->Batteries->Rechargable","children":null},{"id":3,"title":"Computers","path":"Electronics->Accessories->Computers","children":[{"id":4,"title":"Components","children":[{"id":5,"title":"Laptops","children":[]}]}]},{"id":4,"title":"Components","path":"Electronics->Accessories->Computers->Components","children":[{"id":5,"title":"Laptops","children":[]}]},{"id":5,"title":"Laptops","path":"Electronics->Accessories->Computers->Components->Laptops","children":null},{"id":11,"title":"Cars","path":"Cars","children":[{"id":12,"title":"Electronics","children":[{"id":13,"title":"Accessories","children":[{"id":14,"title":"Chargers","children":[]}]}]}]},{"id":12,"title":"Electronics","path":"Cars->Electronics","children":[{"id":13,"title":"Accessories","children":[{"id":14,"title":"Chargers","children":[]}]}]},{"id":13,"title":"Accessories","path":"Cars->Electronics->Accessories","children":[{"id":14,"title":"Chargers","children":[]}]},{"id":14,"title":"Chargers","path":"Cars->Electronics->Accessories->Chargers","children":null},{"id":15,"title":"DIY","path":"DIY","children":[{"id":16,"title":"Power Tools","children":[{"id":17,"title":"Accessories","children":[{"id":18,"title":"Batteries","children":[]}]}]}]},{"id":16,"title":"Power Tools","path":"DIY->Power Tools","children":[{"id":17,"title":"Accessories","children":[{"id":18,"title":"Batteries","children":[]}]}]},{"id":17,"title":"Accessories","path":"DIY->Power Tools->Accessories","children":[{"id":18,"title":"Batteries","children":[]}]},{"id":18,"title":"Batteries","path":"DIY->Power Tools->Accessories->Batteries","children":null}]
and here i use map() method from where prop catList is passed to component:
this.props.searchRes.map(cat => {
if(!cat.path.includes('->')){
categories.push(<SearchResult filtered={false} title={cat.title} id={cat.id} catList={cat} key={cat.id}/>)
}
})