Search code examples
javascriptreactjssocket.iorefuse-ref

React: ref to child component is null after remounting by changing key


For a 1v1 Sudoku game, my GamePage component renders the main Game component, which contains a Clock for each player. When both players agree to a rematch, the entire Game is reset by simply incrementing its key by 1 (after changing the GamePage state to reflect the settings of the new game).

My Problem:
Game stores two refs this.myClock and this.opponentClock to the countdowns inside of both clocks, so they can be paused/started when a player fills a square. This works perfectly fine for the first game. However, after Game remounts, any move will throw "Cannot read properties of null (reading 'start')" at e.g. this.opponentClock.current.start().

I know that refs are set to null when a component unmounts, but by rendering a new version of Game, I would expect them to be set in the constructor again. To my surprise, the new timers are set correctly and one of them is running (which is also done in componentDidMount of Game using the refs), but any access afterwards breaks the app.

I would be incredibly grateful for any tips or remarks about possible causes, I've been stuck on this for two days now and I'm running out of things to google.

GamePage.js:

export default function GamePage(props) {
    const [gameCounter, setGameCounter] = useState(0) //This is increased to render a new game
    const [gameDuration, setGameDuration] = useState(0)
    ...
    useEffect(() =>{
        ...
        socket.on('startRematch', data=>{
            ...
            setGameDuration(data.timeInSeconds*1000)
            setGameBoard([data.generatedBoard, data.generatedSolution])
            setGameCounter(prevCount => prevCount+1)
        })
    },[]) 
    
    return (
        <Game key={gameCounter} initialBoard={gameBoard[0]} solvedBoard={gameBoard[1]} isPlayerA={isPlayerA} 
        id={gameid} timeInMs={gameDuration} onGameOver={handleGamePageOver}/> 
    )
}

Game.js:

class Game extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            gameBoard: props.initialBoard, 
            isPlayerANext: true,
            gameLoser: null, //null,'A','B'
        };
        this.myClock = React.createRef(); 
        this.opponentClock = React.createRef();
    }

    componentDidMount(){
        if(this.props.isPlayerA){
            this.myClock.current.start()
        }
        else{
            this.opponentClock.current.start()
        }
        socket.on('newMove', data =>{
            if(data.isPlayerANext===this.props.isPlayerA){
                this.opponentClock.current.pause()
                this.myClock.current.start()
            }
            else{
                this.opponentClock.current.start()
                this.myClock.current.pause()
            }
        })
        ...
    }
    
    render(){
        return( 
        <React.Fragment>
            <Clock ref={this.opponentClock} .../>
            <Board gameBoard={this.state.gameBoard} .../>
            <Clock ref={this.myClock} .../>
        </React.Fragment>)
        ...
    }
}

export default Game

Clock.js:

import Countdown, { zeroPad } from 'react-countdown';

const Clock = (props,ref) => {
    const [paused, setPaused] = useState(true);
    return <Countdown ref={ref} ... />
}

export default forwardRef(Clock);

Edit: Accepted answer works like a charm. The problem was not the new ref per se, but that socket.on('newMove',...) and socket.on('surrender',...) using the old refs were not cleaned up properly on unmount of the old game.


Solution

  • I am very happy to let you know that after around 2 hours of debugging (lol) I have found the source of your problem.

    The problem is you were not cleaning up your socket.on functions on component unmount so the old ones were still there with references to old refs.

    Look at the way I am doing it here, to clean up the functions and your problem will be solved:

    class Game extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          gameBoard: props.initialBoard,
          isPlayerANext: true,
          gameLoser: null, //null,'A','B'
        };
        this.solvedBoard = props.solvedBoard;
        this.wrongIndex = -1;
        this.handleSquareChange = this.handleSquareChange.bind(this);
        this.myClock = React.createRef();
        this.opponentClock = React.createRef();
        this.endTime = Date.now() + props.timeInMs; //sets both clocks to the chosen time
        this.handleTimeOut = this.handleTimeOut.bind(this);
        this.onNewMove = this.onNewMove.bind(this);
        this.onSurrender = this.onSurrender.bind(this);
      }
    
      isDraw() {
        return !this.state.gameLoser && this.state.gameBoard === this.solvedBoard;
      }
    
      onNewMove(data) {
        console.log('NewMoveMyClock: ', this.myClock.current);
        if (data.isPlayerANext === this.props.isPlayerA) {
          console.log(
            'oppmove: ',
            this.myClock.current,
            this.opponentClock.current
          );
          this.opponentClock.current.pause();
          this.myClock.current.start();
        } else {
          console.log('mymove: ', this.myClock.current, this.opponentClock.current);
          this.opponentClock.current.start();
          this.myClock.current.pause();
        }
        let idx = data.col + 9 * data.row;
        let boardAfterOppMove =
          this.state.gameBoard.substring(0, idx) +
          data.val +
          this.state.gameBoard.substring(idx + 1);
        this.wrongIndex = data.gameLoser ? idx : this.wrongIndex;
        this.setState({
          gameBoard: boardAfterOppMove,
          gameLoser: data.gameLoser,
          isPlayerANext: data.isPlayerANext,
        });
        if (data.gameLoser) {
          this.handleGameOver(data.gameLoser);
        } else if (this.isDraw()) {
          this.handleGameOver(null);
        }
      }
    
      onSurrender(data) {
        this.handleSurrender(data.loserIsPlayerA);
      }
    
      componentDidMount() {
        console.log('component game did mount');
        console.log(
          this.myClock.current.initialTimestamp,
          this.myClock ? this.myClock.current.state.timeDelta.total : null,
          this.opponentClock
            ? this.opponentClock.current.state.timeDelta.total
            : null,
          this.props.gameCounter
        );
        if (this.props.isPlayerA) {
          this.myClock.current.start();
        } else {
          this.opponentClock.current.start();
        }
        socket.on('newMove', this.onNewMove);
    
        socket.on('surrender', this.onSurrender);
      }
    
      componentWillUnmount() {
        socket.off('newMove', this.onNewMove);
        socket.off('surrender', this.onSurrender);
      }