How I solve React's functional component and hooks limitation that cause a lot of troubles/bugs

bi_khi_647aa6dba9175191

Bùi Khôi

Posted on May 26, 2024

How I solve React's functional component and hooks limitation that cause a lot of troubles/bugs

I. What's the limitation?

Recently, I took over a project that mostly work on realtime communication(messaging, video call, integrate with different vendors using different sockets). At the moment I joined, there're bunch of bugs that related to glitches stuff.
Take a look at the code below:

const Page = () => {
  const [roomName, setRoomName] = useState();
  const [numberOfParticipant, setNumberOfParticipant] = useState();
  const connection = useVideoCallConnection();

  useEffect(() => {
    const conference = connection.createConference(roomName);

    conference.on('JOINED', () => {
      if (numberOfParticipant > 2) {
        conference.setViewMode('grid');
      }
    });

    return () => {
      conference.off('JOINED');
      conference.disconnect();
    };
  }, [roomName, connection]);

  // Component UI
};
Enter fullscreen mode Exit fullscreen mode

It seems fine, but not…

The main issue is: sometime when createConference took a few seconds, someone joined the room in the middle of the creation. numberOfParticipants inside remained the same(numberOfParticipants is number and wasn't included in the dependencies list). It cause the grid layout turned off, even if the participants number larger than two.

You might wonder: "why don't we just add numberOfParticipants to the dep list?"… That's what I thought in the beggining too.

const Page = () => {
  const [roomName, setRoomName] = useState();
  const [numberOfParticipant, setNumberOfParticipant] = useState();
  const connection = useVideoCallConnection();

  useEffect(() => {
    const conference = connection.createConference(roomName);

    conference.on('JOINED', () => {
      if (numberOfParticipant > 2) {
        conference.setViewMode('grid');
      }
    });

    return () => {
      conference.off('JOINED');
      conference.disconnect();
    };
  }, [roomName, connection, numberOfParticipant]);

  // Component UI
};
Enter fullscreen mode Exit fullscreen mode

I tried and fixed the problem with grid view but ended up with a new issue: when numberOfParticipant changed, it's disconnect the current conference and re-create a new one...

Because the first solution didn't work, I tried another way by seperating the conference init hook into two useEffect hook:


const Page = () => {
  const [roomName, setRoomName] = useState();
  const [numberOfParticipant, setNumberOfParticipant] = useState();
  const [conference, setConference] = useState();
  const connection = useVideoCallConnection();

  useEffect(() => {
    if (conference) {
      conference.on('JOINED', () => {
        if (numberOfParticipant > 2) {
          conference.setViewMode('grid');
        }
      });
    }

    return () => {
      if (conference) {
        // Call to this method will throw an error, because conference is already off
        conference.off('JOINED');
      }
    }
  }, [conference, numberOfParticipant]);

  useEffect(() => {
    const _conference = connection.createConference(roomName) ;

    _conference.connect();
    setConference(_conference);

    _conference.on('LEAVE', () => {
      setConference (null);
    });

    return () => {
      _conference.disconnect();
    }

  }, [roomName, connection]);

  // Component UI
};
Enter fullscreen mode Exit fullscreen mode

By moving setViewMode to another useEffect, we solved the issue(changing the numberOfParticipant only remove the previous listener instance and add a new one)… So the new code is good, isn't it?

Unfortunately, it's not. It worked fine when joining conference but not when leaving.

When the user left the conference(roomName changed). that one was disconnected and set to null. When it was changed, the conferece.off('joined') is called and will throw an execption due to the disconnection happened before.

After a while of trying, I figure it out the limitation here is: If an outside vavariable value is primnitive, I can't get the latest value of it inside useEffect without including it in the dependencies list and re-run the cleanup everytime(react mentioned the same thing here https://react.dev/learn/separating-events-from-effects#extracting-non-reactive-logic-out-of-effects)

II. Why It happened?

useEffect accepts two params

  • A function declaration(memorized per depdencies list)
  • An array of dependencies list.

Since the function declaration is memorized, the outside variable with priminitive value which is used inside the function will also be memorized per dependencies list. Including a value in the depdencies list make it reactive(changing the variable's value trigger an effect and its cleanup)

III. How I solved that?

Because the rootcause is: variable with priminitive value inside useEffect, we can solve it by duplicate the value with a ref.

const Page = () => {
  const [roomName, setRoomName] = useState();
  const [numberOfParticipant, setNumberOfParticipant] = useState();
  const connection = useVideoCallConnection();
  // Create a ref to use inside effect
  const numberOfParticipantRef = useRef(numberOfParticipant);
  numberOfParticipantRef.current = numberOfParticipant;

  useEffect(() => {
    const conference = connection.createConference(roomName);

    conference.on('JOINED', () => {
      // This is reference so it works now!!
      if (numberOfParticipantRef.current > 2) {
        conference.setViewMode('grid');
      }
    });

    return () => {
      conference.off('JOINED');
      conference.disconnect();
    };
  }, [roomName, connection]);

  // Component UI
};
Enter fullscreen mode Exit fullscreen mode

numberOfParticipantRef is an object so we could get the latest value inside the effect without re-run anything.
Since my project has a bunch of cases like this and I don't want to do the duplication for all files. I created an npm package hook and a babel plugin to do the job for me

import useNonReactiveState from 'use-none-reactive-state';

const Page = () => {
  // Just replace useState with useNonReactiveState and use the babel plugin
  const [roomName, setRoomName] = useNonReactiveState();
  const [numberOfParticipant, setNumberOfParticipant] = useNonReactiveState();
  const connection = useVideoCallConnection();

  useEffect(() => {
    const conference = connection.createConference(roomName);

    conference.on('JOINED', () => {
      if (numberOfParticipant > 2) {
        conference.setViewMode('grid');
      }
    });

    return () => {
      conference.off('JOINED');
      conference.disconnect();
    };
  }, [roomName, connection]);

  // Component UI
};
Enter fullscreen mode Exit fullscreen mode

IV. Conclusion

That's how I solved the problem. Please gimme feedbacks If anything is not correct, I really appreciate that. If you feel interest in the package and the plugin, here's the links:

💖 💪 🙅 🚩
bi_khi_647aa6dba9175191
Bùi Khôi

Posted on May 26, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Is this real?
webdev Is this real?

November 28, 2024

Will AI make software disappear?
My Github project
webdev My Github project

November 26, 2024