UseEffect hook with socket.io state is not persistent in socket handlers
Asked Answered
P

2

30

I have the following react component

function ConferencingRoom() {
    const [participants, setParticipants] = useState({})
    console.log('Participants -> ', participants)

    useEffect(() => {
        // messages handlers
        socket.on('message', message => {
            console.log('Message received: ' + message.event)
            switch (message.event) {
                case 'newParticipantArrived':
                    receiveVideo(message.userid, message.username)
                    break
                case 'existingParticipants':
                    onExistingParticipants(
                        message.userid,
                        message.existingUsers
                    )
                    break
                case 'receiveVideoAnswer':
                    onReceiveVideoAnswer(message.senderid, message.sdpAnswer)
                    break
                case 'candidate':
                    addIceCandidate(message.userid, message.candidate)
                    break
                default:
                    break
            }
        })
        return () => {}
    }, [participants])

    // Socket Connetction handlers functions

    const onExistingParticipants = (userid, existingUsers) => {
        console.log('onExistingParticipants Called!!!!!')

        //Add local User
        const user = {
            id: userid,
            username: userName,
            published: true,
            rtcPeer: null
        }

        setParticipants(prevParticpants => ({
            ...prevParticpants,
            [user.id]: user
        }))

        existingUsers.forEach(function(element) {
            receiveVideo(element.id, element.name)
        })
    }

    const onReceiveVideoAnswer = (senderid, sdpAnswer) => {
        console.log('participants in Receive answer -> ', participants)
        console.log('***************')

        // participants[senderid].rtcPeer.processAnswer(sdpAnswer)
    }

    const addIceCandidate = (userid, candidate) => {
        console.log('participants in Receive canditate -> ', participants)
        console.log('***************')
        // participants[userid].rtcPeer.addIceCandidate(candidate)
    }

    const receiveVideo = (userid, username) => {
        console.log('Received Video Called!!!!')
        //Add remote User
        const user = {
            id: userid,
            username: username,
            published: false,
            rtcPeer: null
        }

        setParticipants(prevParticpants => ({
            ...prevParticpants,
            [user.id]: user
        }))
    }

    //Callback for setting rtcPeer after creating it in child component
    const setRtcPeerForUser = (userid, rtcPeer) => {
        setParticipants(prevParticpants => ({
            ...prevParticpants,
            [userid]: { ...prevParticpants[userid], rtcPeer: rtcPeer }
        }))
    }

    return (
            <div id="meetingRoom">
                {Object.values(participants).map(participant => (
                    <Participant
                        key={participant.id}
                        participant={participant}
                        roomName={roomName}
                        setRtcPeerForUser={setRtcPeerForUser}
                        sendMessage={sendMessage}
                    />
                ))}
            </div>
    )
}

the only state it has is a hashTable of participants inside the call using useState hook to define it.

then I'm using useEffect to listen on the socket events for the chat room just 4 events

then After that, I'm defining the 4 callback handlers for those events with respect to there order of execution on the server

and last I have another callback function that gets passed to every child participant in the list so that after the child component creates its rtcPeer object it send it to the parent to set it on the participant object in the participant's hashTable

The flow goes like this participants join the room -> existingParticipants event gets called -> local participant gets created and added to the participants hashTable then -> recieveVideoAnswer and candidate gets emitted by the server multiple time as you can see in the screenshot

the first event the state is empty the subsequent two events its there then it's empty again and this pattern keeps repeating one empty state then the following two is correct and I have no idea what's going on with the state

enter image description here

Phil answered 22/2, 2019 at 9:34 Comment(11)
You are not giving an empty array as second argument to useEffect, so you will create a new listener for every render. Is that really what you want? It's also a good idea to return a cleanup function from the function given to useEffect so that the listener will be removed when the ConferencingRoom component is unmounted.Abrasion
@Abrasion if I gave an empty array the state becomes empty all the time and no that's not what I wantPhil
Possible duplicate of Calling `useVal` several times in a single function with arrays - unexpected behaviorAspirate
@RyanCogswell I'm not calling my setParticpants at any function multiple times, and the probelm isn't with the setParticipants its with reading the particpants state everytime a socket event callback gets called, the callbacks will fire multiple times as you see in the screenshot and every two times the state changes to empty without touching it.Phil
In addition to not using the functional update syntax (see duplicate I mentioned), you are setting up your socket handler with each re-render without cleaning up the previous handler.Aspirate
As Tholle indicated you need the empty dependency array so that you only set up one handler, then you need to use the functional update syntax so that you aren’t using stale state in your handler functions.Aspirate
@RyanCogswell I did all this and updated the question I'm now just getting empty state everywhere, Again the problem with reading the state first callback fires state is empty, two subsequent callbacks fire after it state is there, the fourth one fires state is empty again, and no any code during this touches the state.....Phil
In onExistingParticipants you are still referencing ...participants instead of ...prevParticipants.Aspirate
Also, your console.log at the beginning of your message handler is meaningless — it is always going to log the initial state (so long as your dependency array is correct), but the functional update syntax in the other methods will receive the current state.Aspirate
If you want to check your state in the console, put the log outside of the useEffect (e.g. right after your useState call).Aspirate
@RyanCogswell Can you please tell how can I make my dependency array correct, I don't even Understand the concept of it or why I need, I already went through the docs and all I understood is you put there the state variables that you want this effect to be triggered only when they change.Phil
A
55

The difficult thing about this is that you had several issues interacting with one another that were confusing your troubleshooting.

The biggest issue is that you are setting up multiple socket event handlers. Each re-render, you are calling socket.on without having ever called socket.off.

There are three main approaches I can picture for how to handle this:

  • Set up a single socket event handler and only use functional updates for the participants state. With this approach, you would use an empty dependency array for useEffect, and you would not reference participants anywhere within your effect (including all of the methods called by your message handler). If you do reference participants you'll be referencing an old version of it once the first re-render occurs. If the changes that need to occur to participants can easily be done using functional updates, then this might be the simplest approach.

  • Set up a new socket event handler with each change to participants. In order for this to work correctly, you need to remove the previous event handler otherwise you will have the same number of event handlers as renders. When you have multiple event handlers, the first one that was created would always use the first version of participants (empty), the second one would always use the second version of participants, etc. This will work and gives more flexibility in how you can use the existing participants state, but has the down side of repeatedly tearing down and setting up socket event handlers which feels clunky.

  • Set up a single socket event handler and use a ref to get access to the current participants state. This is similar to the first approach, but adds an additional effect that executes on every render to set the current participants state into a ref so that it can be accessed reliably by the message handler.

Whichever approach you use, I think you will have an easier time reasoning about what the code is doing if you move your message handler out of your rendering function and pass in its dependencies explicitly.

The third option provides the same kind of flexibility as the second option while avoiding repeated setup of the socket event handler, but adds a little bit of complexity with managing the participantsRef.

Here's what the code would look like with the third option (I haven't tried to execute this, so I make no guarantees that I don't have minor syntax issues):

const messageHandler = (message, participants, setParticipants) => {
  console.log('Message received: ' + message.event);

  const onExistingParticipants = (userid, existingUsers) => {
    console.log('onExistingParticipants Called!!!!!');

    //Add local User
    const user = {
      id: userid,
      username: userName,
      published: true,
      rtcPeer: null
    };

    setParticipants({
      ...participants,
      [user.id]: user
    });

    existingUsers.forEach(function (element) {
      receiveVideo(element.id, element.name)
    })
  };

  const onReceiveVideoAnswer = (senderid, sdpAnswer) => {
    console.log('participants in Receive answer -> ', participants);
    console.log('***************')

    // participants[senderid].rtcPeer.processAnswer(sdpAnswer)
  };

  const addIceCandidate = (userid, candidate) => {
    console.log('participants in Receive canditate -> ', participants);
    console.log('***************');
    // participants[userid].rtcPeer.addIceCandidate(candidate)
  };

  const receiveVideo = (userid, username) => {
    console.log('Received Video Called!!!!');
    //Add remote User
    const user = {
      id: userid,
      username: username,
      published: false,
      rtcPeer: null
    };

    setParticipants({
      ...participants,
      [user.id]: user
    });
  };

  //Callback for setting rtcPeer after creating it in child component
  const setRtcPeerForUser = (userid, rtcPeer) => {
    setParticipants({
      ...participants,
      [userid]: {...participants[userid], rtcPeer: rtcPeer}
    });
  };

  switch (message.event) {
    case 'newParticipantArrived':
      receiveVideo(message.userid, message.username);
      break;
    case 'existingParticipants':
      onExistingParticipants(
          message.userid,
          message.existingUsers
      );
      break;
    case 'receiveVideoAnswer':
      onReceiveVideoAnswer(message.senderid, message.sdpAnswer);
      break;
    case 'candidate':
      addIceCandidate(message.userid, message.candidate);
      break;
    default:
      break;
  }
};

function ConferencingRoom() {
  const [participants, setParticipants] = React.useState({});
  console.log('Participants -> ', participants);
    const participantsRef = React.useRef(participants);
    React.useEffect(() => {
        // This effect executes on every render (no dependency array specified).
        // Any change to the "participants" state will trigger a re-render
        // which will then cause this effect to capture the current "participants"
        // value in "participantsRef.current".
        participantsRef.current = participants;
    });

  React.useEffect(() => {
    // This effect only executes on the initial render so that we aren't setting
    // up the socket repeatedly. This means it can't reliably refer to "participants"
    // because once "setParticipants" is called this would be looking at a stale
    // "participants" reference (it would forever see the initial value of the
    // "participants" state since it isn't in the dependency array).
    // "participantsRef", on the other hand, will be stable across re-renders and 
    // "participantsRef.current" successfully provides the up-to-date value of 
    // "participants" (due to the other effect updating the ref).
    const handler = (message) => {messageHandler(message, participantsRef.current, setParticipants)};
    socket.on('message', handler);
    return () => {
      socket.off('message', handler);
    }
  }, []);

  return (
      <div id="meetingRoom">
        {Object.values(participants).map(participant => (
            <Participant
                key={participant.id}
                participant={participant}
                roomName={roomName}
                setRtcPeerForUser={setRtcPeerForUser}
                sendMessage={sendMessage}
            />
        ))}
      </div>
  );
}

Also, below is a working example simulating what is happening in the above code, but without using socket in order to show clearly the difference between using participants vs. participantsRef. Watch the console and click the two buttons to see the difference between the two ways of passing participants to the message handler.

import React from "react";

const messageHandler = (participantsFromRef, staleParticipants) => {
  console.log(
    "participantsFromRef",
    participantsFromRef,
    "staleParticipants",
    staleParticipants
  );
};

export default function ConferencingRoom() {
  const [participants, setParticipants] = React.useState(1);
  const participantsRef = React.useRef(participants);
  const handlerRef = React.useRef();
  React.useEffect(() => {
    participantsRef.current = participants;
  });

  React.useEffect(() => {
    handlerRef.current = message => {
      // eslint will complain about "participants" since it isn't in the
      // dependency array.
      messageHandler(participantsRef.current, participants);
    };
  }, []);

  return (
    <div id="meetingRoom">
      Participants: {participants}
      <br />
      <button onClick={() => setParticipants(prev => prev + 1)}>
        Change Participants
      </button>
      <button onClick={() => handlerRef.current()}>Send message</button>
    </div>
  );
}

Edit Executable example

Aspirate answered 22/2, 2019 at 15:57 Comment(6)
Is there still not a better way to do this? It's pretty crazy to constantly open and close connectionsAceves
@Aceves I realize your comment was a long time ago, but if you're still looking for another option, I've updated this answer with a third approach (using a ref) that I've used in cases where I have an effect with a dependency that I don't want in the dependency array due to the timing of when I want the effect to execute.Aspirate
@Ryan super helpful explanation, how come calling setParticipants (or any setState function) within the useEffects sets the current (and not the lexically scoped variable)?Spiculum
@Ryan This is a wonderful example on how to tackle a real world problem with usage of hooks. thank you very much!Jasso
thank you, you saved my day!!! my problem was that i was using the old state in the socket.on in the useeffect, using refs instead seems to fix it, I still don't quite understand why but thank you so much!Monasticism
refs works the bestSublingual
D
0

You create a singleton socket object and with the help of closure you can use this instance in any other handler.

// socketHandleInitializer.js

let socket=null;

// when we call this function on top level our app, socket will be initialized
export const socketConnection=()=>{
    socket = io("http://localhost:5000", {optionsObj});
    // you can write listeners here
    socket.on("connect", () => {
        console.log("succesfully connected with scoket io server");
   }
}
// you can write functions to emit event to the server here
// Because you have access to socket obj
// you can use those functions alone anywhere in your app
export const joinRoom = (data) => {
  socket.emit("room-join", data);
};
Dust answered 28/1, 2023 at 7:54 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.