1

I have a SignalR (WebSocket) server for a game, and a React/Redux client that connects to it. I have a component whose task is to listen to SignalR messages to handle them, which usually involves updating something in the Redux state. One of my messages, however, is called "BattleEvents", and this message is actually multiple events packed into one single message. When I receive this, I simply loop through each event in the message and call its corresponding handler function. This is a minimal example of this component:

function BattleEventListener({
  roomId
}: BattleEventListenerProps) {
  const dispatch = useDispatch();
  const roomState = useSelector((state: RootState) => state.rooms.byId[roomId]);
  const roomRef = useRef(roomState);

  useEffect(() => {
    if (gameHub.hub === null) return;
    console.info("Connecting room to gameHub.");

    gameHub.hub.on('BattleEvents', handleBattleEvents);

    return () => {
      gameHub.hub?.off('BattleEvents', handleBattleEvents);
    }
  }, [dispatch, gameHub.hub, roomRef.current]);

  useEffect(() => {
    roomRef.current = roomState;
  }, [roomState]);

  return null;

  async function handleBattleEvents (evtId: string, roomId: string, events: BattleEvent[]) {
    console.debug('<< BattleEvents', { evtId, roomId, events });

    for (let evt of events) {
      try {
        //...
        else if (evt.type === 'takeDamage') handleTakeDamage(evtId, roomId, evt);
        //...
      }
      catch (err) {
        //...
      }
    }
  }

  // ...

  function handleTakeDamage (
    evtId: string, roomId: string, evt: TakeDamageEvent
  ) {
    const room = roomRef.current;

    const fighter = room.battle.players[evt.playerId].fighters[evt.fighterId];

    const oldHpPerc = fighter.currentHpPerc; // <-- Here we are reading an outdated value

    dispatch(roomsActions.setFighterHpPerc({
      roomId,
      playerId: evt.playerId,
      fighterId: evt.fighterId,
      hpPerc: evt.currentHpPerc,
    }));

    console.log(oldHpPerc) // <-- will always print 1 (the initial value) in the first batch of events.
  }
}

The problem in this code is that, when we receive, for example, 3 events in one message, we'll call handleTakeDamage() 3 times, and all of these calls will have the initial room value. Every time I read fighter.currentHpPerc, I'll get one, even if the previous call set that variable to a different value (e.g. 0.73). The next time we receive a "BattleEvents" message and process a new batch of events, we'll read the last value set by the previous handlers (e.g. if the last call set it to 0.73, now all the handleTakeDamage() we handle ini this batch will read 0.73).

I suspect this occurs because the value in roomState received by useSelector will not be updated until all 3 calls to handleTakeDamage() are completed. Adding await Promise.resolve() to every loop of the for loop (which makes every loop take one js 'frame') solves this problem, which seems to confirm my suspicion. However, that is a hack and I'm sure there's a correct way to solve this problem, as my use case shouldn't be uncommon.

1 Answer 1

0

Selecting the state and storing it into a React ref that is updated by another useEffect call is a bit of overkill. I recommend using React-Redux's useStore hook to have an instance of the store from which the code can call getState and get the current state value. This avoids needing to wait for the outer React component context to update and rerender and update the React ref. Since BattleEventListener doesn't actually render anything either, I suggest converting it to a custom React hook, to be called in any component where it's currently "rendered", e.g. calling useBattleEventListener({ roomId: ... }) instead of return (... <BattleEventListener roomId={...} /> ...);.

Example:

function useBattleEventListener({ roomId }: BattleEventListenerProps) {
  const dispatch = useDispatch();
  const store = useStore();

  useEffect(() => {
    if (gameHub.hub === null) return;
    console.info("Connecting room to gameHub.");

    gameHub.hub.on('BattleEvents', handleBattleEvents);

    return () => {
      gameHub.hub?.off('BattleEvents', handleBattleEvents);
    }
  }, [gameHub.hub]);

  async function handleBattleEvents (
    evtId: string,
    roomId: string,
    events: BattleEvent[]
  ) {
    console.debug('<< BattleEvents', { evtId, roomId, events });

    for (let evt of events) {
      try {
        //...
        else if (evt.type === 'takeDamage') handleTakeDamage(evtId, roomId, evt);
        //...
      }
      catch (err) {
        //...
      }
    }
  }

  // ...

  function handleTakeDamage (
    evtId: string, roomId: string, evt: TakeDamageEvent
  ) {
    // Access current Redux store state value
    const state = store.getState();

    // Select the current room value
    const room = state.rooms.byId[roomId];

    const fighter = room.battle.players[evt.playerId].fighters[evt.fighterId];

    const currentFighterHpPerc = fighter.currentHpPerc;

    dispatch(roomsActions.setFighterHpPerc({
      roomId,
      playerId: evt.playerId,
      fighterId: evt.fighterId,
      hpPerc: evt.currentHpPerc,
    }));

    console.log({ currentFighterHpPerc });
  }
}

Since handleBattleEvents and handleTakeDamage are technically external dependencies for the useEffect hook you may well want to memoizes these callback and add them to the useEffect hook's dependency array, or move them into the useEffect hook callback so they are no longer externally dependent. Note that other dependencies may then be surfaced, like store and anything else these callback functions reference.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.