Home > Software design >  React Hooks: Two sibling components that can control each others' state
React Hooks: Two sibling components that can control each others' state

Time:12-09

I've been writing a chess application in order to help myself get up to speed on hooks introduced in React. I've got two main components so far; one for the board itself and one for a move history that allows you to revert back to a previous move. When I try to use a callback in the Board component to pass a move to the move history, I get an error Cannot update a component ('App') while rendering a different component ('MoveHistory'). I understand the error but I'm not fully sure what I'm supposed to do about it.

My components (minus all the parts I'm pretty sure are irrelevant) are as follows:

App.tsx (parent component)

...
const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'

function App() {
  const [FEN, setFEN] = useState(STARTING_FEN);
  const [moveHistory, setMoveHistory] = useState<string[]>([]);
  const [fenHistory, setFenHistory] = useState<string[]>([]);

  // rewind game state to specified move index
  function onRewind(target: number) {
    setFEN(fenHistory[target]);
    setMoveHistory(moveHistory.slice(0, target));
    setFenHistory(fenHistory.slice(0, target));
  }

  // add a new move to the move history
  function onMove(move: string, FEN: string) {
    setMoveHistory([...moveHistory, move]);
    setFenHistory([...fenHistory, FEN]);
  }

  return (
    <div className='app'>
      <Board FEN={FEN} onMove={onMove} />
      <MoveHistory moves={moveHistory} onRewind={onRewind} />
    </div>
  );
}

export default App;

Board.tsx (sibling component 1)

...
interface BoardProps {
  FEN: string;
  onMove: Function;
}

function Board(props: BoardProps) {
  const splitFEN = props.FEN.split(' ');
  const [squares, setSquares] = useState(generateSquares(splitFEN[0]));
  const [lastClickedIndex, setLastClickedIndex] = useState(-1);
  const [activeColor, setActiveColor] = useState(getActiveColor(splitFEN[1]));
  const [castleRights, setCastleRights] = useState(getCastleRights(splitFEN[2]));
  const [enPassantTarget, setEnPassantTarget] = useState(getEnPassantTarget(splitFEN[3]));
  const [halfMoves, setHalfMoves] = useState(parseInt(splitFEN[4]));
  const [fullMoves, setFullMoves] = useState(parseInt(splitFEN[5]));

  ...

  // handle piece movement (called when a user clicks on a square)
  function onSquareClicked(index: number) {
    ... // logic determining where to move the piece
    {      
      props.onMove(moveName, getFEN())
    }
  }

  ...

  // get the FEN string for the current board
  function getFEN(): string {
    ... //logic converting board state to strings
    return `${pieceString} ${activeString} ${castleString} ${enPassantString} ${halfMoves} ${fullMoves}`;
  }

  return (
    <div className='board'>
      {[...Array(BOARD_SIZE)].map((e, rank) => {
        return (
          <div key={rank} className='row'>
            {squares.slice(rank * BOARD_SIZE, BOARD_SIZE   rank * BOARD_SIZE).map((square, file) => {
              return (
                <Square
                  key={file}
                  index={coordsToIndex(rank, file)}
                  pieceColor={square.pieceColor}
                  pieceType={square.pieceType}
                  style={square.style}
                  onClick={onSquareClicked}
                />
              );
            })}
          </div>
        )
      })};
    </div>
  );
}
  
export default Board;

MoveHistory.tsx (sibling component #2)

...
interface MoveHistoryProps {
  moves: string[],
  onRewind: Function;
}

function MoveHistory(props: MoveHistoryProps) {
  return (
    <div className='move-history'>
      <div className='header'>
        Moves
      </div>
      <div className='move-list'>
        {_.chunk(props.moves, 2).map((movePair: string[], index: number) => {
          return (
            <div className='move-pair' key={index}>
              <span>{`${index   1}.`}</span>
              <span onClick={props.onRewind(index * 2)}>{movePair[0]}</span>
              <span onClick={props.onRewind(index * 2   1)}>{movePair[1] || ""}</span>
            </div>
          )
        })}
      </div>
    </div>
  )
}
  
export default MoveHistory;

I've looked at a bunch of other stackoverflow questions that seem to answer the question I'm asking here but to me it looks like I'm already doing what's recommended there, so I'm not sure what the difference is. I've also seen some recommendations to use Redux for this, which I'm not opposed to, but if it can be avoided that would be nice.

CodePudding user response:

The issue is that you are calling props.onRewind in the render of your MoveHistory. This is effectively what happens:

  1. App starts rendering
  2. MoveHistory starts rendering, and calls onRewind
  3. Within onRewind, you call various useState setter methods within App. App hasn't finished rendering yet, but it's state-modifying methods are being called. This is what triggers the error.

I think you mean to do something like this instead:

...
interface MoveHistoryProps {
  moves: string[],
  onRewind: Function;
}

function MoveHistory(props: MoveHistoryProps) {
  return (
    <div className='move-history'>
      <div className='header'>
        Moves
      </div>
      <div className='move-list'>
        {_.chunk(props.moves, 2).map((movePair: string[], index: number) => {
          return (
            <div className='move-pair' key={index}>
              <span>{`${index   1}.`}</span>
              <span onClick={() => props.onRewind(index * 2)}>{movePair[0]}</span>
              <span onClick={() => props.onRewind(index * 2   1)}>{movePair[1] || ""}</span>
            </div>
          )
        })}
      </div>
    </div>
  )
}
  
export default MoveHistory;

Note that, instead of calling props.onRewind you are giving it a method which, when the span is clicked, will call onRewind.

  • Related