1

I'm sure this is a simple React rule, but I'm really struggling to find an answer to this problem because many people make similar mistakes that don't seem to apply to me (async misunderstanding, direct variable mutation, wrong dependency array, etc.) Let me explain.

I want to setup a heartbeat type system where players send messages back and forth every 3 seconds to all competitors to say "I'm alive and still connected." using WebRTC. The connection is fine, and sending/receiving messages works fine. Any player can send data about themselves, a heartbeat, or a few other things, but only one type of data at a time.

I have a function/useCallback (I tried both) called ProcessMessage that reads these incoming messages. If the message contains data on a competitor, it gets set with setCompetitors. If the data contains a heartbeat, it sends back an acknowledgement (or, it should). Heartbeats do arrive correctly.

In ProcessMessage, the state competitors is always the initial value of just []. However, in other places, competitors updates just fine. When I use a breakpoint at the place heartbeats are being read, the competitors state is correct. The only thing I can think of is that the ProcessMessage function reads/sets incoming competitor data and also reads incoming heartbeats. It never does both at the same time, though. Competitor data come in, then a few seconds later, another message with a heart beat comes in, so I don't know why this would cause competitors to always be the initial value. I have competitors in my dependency array at the end of ProcessMessage when I write it as a callback hook.

Just to be clear, this is NOT a case of:

/* Not this kind of problem, I'm pretty sure. I know setCompetitors is async */
setCompetitors(competitorData);
console.log(competitors);

I've cut out as much code as I can while still hopefully leaving enough to make sense of what's happening.

import React, { useCallback, useEffect, useState } from 'react';
import Peer from 'peerjs';

function App() {
  const [myData,      setMyData]      = useState({/* default data, updates fine */});
  const [competitors, setCompetitors] = useState([]);

  /* Create WebRTC connection useEffect here, works fine */

  // Heartbeats to all competitors - sends correctly
  useEffect(() => {
    const heartbeats = [];
    competitors.forEach(competitor => {  // competitors is fine here
      heartbeats.push(setInterval(() => {
        competitor.conn.send({heartbeat : {
          stage: 1,
          playerKey: myData.playerKey
        }});
        console.log("Heartbeat stage 1 sent");  // gets sent correctly
      }, 3000));
    });
    return () => {
      heartbeats.forEach(heartbeat => { clearInterval(heartbeat); });
    }
  }, [competitors]);

  const ProcessMessage = useCallback((data) => {

    /* If the data has competitor data, it won't have a heartbeat too */

    /* check if data contains competitor data */
    const newCompetitor = data.competitor;
    if(newCompetitor) {
      console.log("New competitor data received");
      setCompetitors(oldCompetitors => {
        let newCompetitors = [...oldCompetitors];
        /* Some newCompetitor integration logic, partial updates, error checking, etc */
        return newCompetitors;
      });
    }


    /* Code to deal with other types of data here - works fine */


    /* check if data contains a heartbeat */
    const heartbeat = data.heartbeat;
    if(heartbeat) {

      /* This is the problem. competitors is always just [] */
      const competitor = competitors.find(comp => comp.playerKey === heartbeat.playerKey);
      if(competitor) {
        /* Code to deal with incoming heartbeat - never fires*/
      } else {
        console.log("Heartbeat could not be returned");  // This always fires
      }
    }
  }, [boardData, competitors]);

  /* Lots more logic, functions, etc */
  /* Beautiful return statement */

If any additional code could help clarify what's going on, please ask and I'll update the question. Thanks all.

btw, full code can be seen here: GitHub The offending line is 149

cdehaan
  • 394
  • 5
  • 10
  • 1
    Well-asked question. Do you see the output: "New competitor data received"? If so, how many times? – Nathan Aug 05 '22 at 01:09
  • 2
    Is there a way that you can check each step in the "Some newCompetitor integration logic, partial updates, error checking, etc " is actually working ? It may be bombing and giving you an empty array – Ryan Zeelie Aug 05 '22 at 01:22
  • @Nathan Thank you. I do see "New competitor data received" output once on the host of the game. (The person joining has a similar log message when they send their data, and it fires once at the right time.) – cdehaan Aug 05 '22 at 03:45
  • @RyanZeelie That's a good point. When I set a breakpoint at the final line, ```return newCompetitors;``` it is setting ```competitors``` to what I expect. Also, after that, I can see in developer tools that the data has been stored in the ```competitors``` state correctly. – cdehaan Aug 05 '22 at 03:47

2 Answers2

0

The bug is in this line:

let newCompetitors = [...oldCompetitors];

You're just copying the old state, which is empty.

If should be

let newCompetitors = [...oldCompetitors, newCompetitor];

That way you actually add the new competitor to the state.

Nathan
  • 73,987
  • 14
  • 40
  • 69
  • My syntax on adding to an array after spreading is rusty, so it might not work as is, but you get the idea. – Nathan Aug 05 '22 at 03:52
  • 1
    He's copying the old state and then making changes to newCompetitors before returning it. I think it's safe to assume that it's getting added during the additional logic – Ryan Zeelie Aug 05 '22 at 04:05
  • 1
    Yes, I glossed over this a bit, but there's quite a bit of logic between ```let newCompetitors = [...oldCompetitors];``` and ```return newCompetitors;``` which takes data from ```newCompetitor``` and incorporates it into ```newCompetitors```. I check by putting a breakpoint at the return line, and indeed ```newCompetitors``` has been correctly updated. – cdehaan Aug 05 '22 at 04:18
  • 1
    Okay, that makes sense. Hmm. So the ProcessMessage callback is called multiple times-- and it handles two different types of events. We know that added a competitor works, because you added a breakpoint and see the state being set. Perhaps during the second flow, heartbeat, you're accidentally clobbering the state and resetting it to [] before you reach the heartbeat handling logic? Can you check that the state is set to a non-empty array before AND after you attempt to process the heartbeat? – Nathan Aug 05 '22 at 05:53
  • It's definitely a strange issue. If this doesn't work, my gut is saying it has something to do with the useCallback usage and changing state that is in the dependency array-- though that should still work, and you said you've tried it w/o useCallback – Nathan Aug 05 '22 at 05:55
  • @Nathan Yes, that's a possible reason, but I put a breakpoint on the first line of ```ProcessMessage``` once heartbeats started being passed and even then, ```competitors``` was still ```[]```. Annoyingly, while holding on that breakpoint, I can see the data showing up in the hook correctly over in the "Components" tab in developers tools. – cdehaan Aug 05 '22 at 07:41
0

I've found a workable solution. Even though I don't know why my original code failed, my workaround isn't too messy, so in case I can help anyone else, I'll outline what I did.

First, I made a new state (which could maybe be better thought of as "current heartbeat" actually) as follows:

const [heartbeats,  setHeartbeats]  = useState(null)

Then, I changed the heartbeat processing part of ProcessMessage to the following:

    if(heartbeat) {
        if(heartbeat.stage === 1) {
          setHeartbeats(heartbeat);
        }
  
        if (heartbeat.stage === 2) {
          console.log("Heartbeat stage 2 received:");  // This fires correctly
          console.log(heartbeat); // Looks good
          /* Code to mark this player as "Active" to go here */
        }  
    }

Finally, I added a useEffect that picks up on the change to heartbeats as follows:

  useEffect(() => {
    if(!heartbeats) { return; }
    const competitor = competitors.find(comp => comp.playerKey === heartbeats.playerKey);
    if(competitor) {
      competitor.conn.send({heartbeat : {
        stage: 2,
        playerKey: myData.playerKey
      }});
      console.log("Heartbeat stage 2 sent:"); // This fires now
    } else {
      console.log("Heartbeat could not be returned");
    }
  }, [heartbeats, competitors]);  // Works even if competitors is removed

In this function, competitors is available as expected. That means heartbeats are returned (as stage 2), and my ProcessMessage picks them up as a returned heartbeat.

However, this code seems more fragile, not to mention more complex than it should be. I'm worried setting many heartbeats might result in skipped beats. Also, I've added a state hook that seems unnecessary.

halfer
  • 19,824
  • 17
  • 99
  • 186
cdehaan
  • 394
  • 5
  • 10