Reputation: 6255
So I started learning React and after doing some basics (counters, todo lists) I decided to do something more challenging; a chessboard with pieces that can be moved around by clicking. My main component is Chessboard
. It then renders Tiles
that may contain a Piece
. Information about the game is stored in Chessboard's
state, looking like this:
interface State {
board: Record<string, pieceInterface>;
isPieceMoving: boolean;
movingPieceId: string;
}
I handle moving pieces in Chessboard's
method onTileClick(id: string)
. It is passed to a Tile
as a prop and when a tile is clicked it's called with a tile id (like "a4", "f6", "h3"). It has following logic. Game can be in two states: I can be currently moving a piece or I can currently do nothing and then start moving a piece by clicking on it. When I start moving a piece I store it's ID (or rather an ID of a tile on witch piece stands) in state.movingPieceId
. When I try to place it I check if the tile is empty and then change state.board
accordingly. Here is my code:
onTileClick = (id: string): void => {
if (!this.state.isPieceMoving) {
if (this.state.board[id]) {
this.setState({
isPieceMoving: true,
movingPieceId: id,
});
}
} else {
if (!this.state.board[id]) {
this.setState((state) => {
let board: Record<string, pieceInterface> = state.board;
const piece: pieceInterface = board[state.movingPieceId];
delete board[state.movingPieceId];
board[id] = piece;
// console.log(board[id]);
// console.lob(board);
const isPieceMoving: boolean = false;
const movingPieceId: string = "";
return { board, isPieceMoving, movingPieceId };
});
}
}
};
The first part works just fine. But in the second there is some bug, that I cannot find. When I uncomment this console logs the output looks like this (I want to move a piece from "a2" to "a3"):
Object { "My piece representation" }
Object {
a1: { "My piece representation" },
a3: undefined,
a7: { "My piece representation" },
...
}
My code properly "takes" clicked piece from "a2" (board has no "a2" property) but it apparently does not place it back. Even when printing board[id]
gives correct object! I was moving logging line console.log(board)
up to see where bug happens. And even when I put it directly behind else it still was saying that "a3" was undefined
.
I spend hours trying to understand what was happening. I tried to emulate this fragment of code in console, but there it always worked! Can anybody explain to me what am I doing wrong? Is this some weird React's setState mechanism that I haven't learned yet? Or is it some basic javascript thing, that I am not aware of? Any help would we wonderful, because I cannot move on and do anything else while being stuck here.
EDIT #1
Ok. So I was able to fix this bug by deep copying state.board
into board
(inspiredy by @Linda Paiste's comment). I just used simple JSON stringify/parse hack:
let board: Record<string, pieceInterface> = JSON.parse(JSON.stringify(state.board));
This fixes the bug but I have no idea why. I will keep this question open, so maybe someone will explain to me what and why was wrong with my previous code.
EDIT #2
Ok. Thanks to @Linda's explanation and reading the docs i finally understood what I was doing wrong. Linda made detailed explanation in an answer below (the accepted one). Thank you all very much!
Upvotes: 2
Views: 1478
Reputation: 42298
let board: Record<string, pieceInterface> = state.board;
You are creating a new variable board
that refers to the same board object as the one in your actual state. Then you mutate that object:
delete board[state.movingPieceId];
board[id] = piece;
This is an illegal mutation of state because any changes to board
will also impact this.state.board
.
Any array or object that you want to change in your state needs to be a new version of that object.
Your deep copy of board
works because this new board
is totally independent of your state
so you can mutate it without impacting the state. But this is not the best solution. It is inefficient and will cause unnecessary renders because every piece object will be a new version as well.
We just need to copy the objects which are changing: the state
and the board
.
this.setState((state) => ({
isPieceMoving: false,
movingPieceId: '',
board: {
// copy everything that isn't changing
...state.board,
// remove the moving piece from its current position
[state.movingPieceId]: undefined,
// place the moving piece in its new location
[id]: state.board[state.movingPieceId],
}
}));
Your typescript type for the board
property of State
should be Partial<Record<string, pieceInterface>>
because not every slot on the board has a piece in it.
Very ugly but functional Code Sandbox demo
I heard that using
setState
with a function makesstate
a copy ofthis.state
, so I can mutate it.
That is incorrect. You should never mutate state and this is no exception. The advantage of using a callback is that the state
argument is guaranteed to be the current value, which is important because setState
is asynchronous and there might be multiple pending updates.
Here's what the React docs say (emphasis added):
state
is a reference to the component state at the time the change is being applied. It should not be directly mutated. Instead, changes should be represented by building a new object based on the input fromstate
andprops
."Both
state
andprops
received by the updater function are guaranteed to be up-to-date.
Upvotes: 1