Using RTCPeerconnection in React: useEffect() vs. useState()

I want to talk about hooking up inbound WebRTC streams or tracks to video
elements in React, in the browser.

What I want to explore is a method of avoiding the useEffect() React hook.

The following code examples will be short, and leave out a lot of detail that would be necessary in a full working example. Because I want to focus on the question of useEffect() vs. useState() in this scenario.

Let's say you want to create a React component for rendering incoming WebRTC
video streams.

You might start by creating some React JSX code like this:

function MyRtcViewer() {
    return (
        <div className="cssclass">
            <video autoplay muted controls>
        </div>
    );
}

Okay, good start. I am going to skip a lot of details, as I want to focus on one question, that of useEffect vs useState. But, lets scaffold out creating the RTCPeerConnection and ontrack setup.

function MyRtcViewer() {
    const pcref = React.useRef<RTCPeerConnection | null>(null)
    if (pcref.current === null) {
        pcref.current = new RTCPeerConnection()
    }

    const vid = document.getElementById('myvid')
    pcref.current.ontrack = (e) => vid.srcObject = e.streams[0]

    // RTC signalling send SDP offer, get SDP answer, etc.

    return (
        <div className="cssclass">
            <video id='myvid' autoplay muted controls>
        </div>
    );
}

I added a few lines: create the PC (RTCPeerConnection), get a handle to the
video element, and assign the stream to the video element when the track event occurs, when the connection is established. I have left out a lot of important details for production code, in order to focus on the main question of the title.

But wait! This code won't work! Why? The JS to get the handle to 'myvid' runs even before the video element has been returned to React to apply against the virtual DOM, and then the real DOM.

So, that document.getElementById() call will fail and return null!

But, that can be easily fixed. (or can it?)

function MyRtcViewer() {
    const pcref = React.useRef<RTCPeerConnection | null>(null)
    if (pcref.current === null) {
        pcref.current = new RTCPeerConnection()
    }

    pcref.current.ontrack = (e) => {
        const vid = document.getElementById('myvid')
        vid.srcObject = e.streams[0]
    }

    // RTC signalling send SDP offer, get SDP answer, etc.

    return (
        <div className="cssclass">
            <video id='myvid' autoplay muted controls>
        </div>
    );
}

Now, the video element won't be searched for until the ontrack event is
invoked, so the video element will usually be found, and everybody is happy!,
right? No, the problem here is React is asynchronous, and you have created a
race condition, one that might not usually occur, but can occur. If it happens 1% of the time, you have just created some buggy software, congrats!

The next thing to try is the React useEffect() hook:

function MyRtcViewer() {

    const pcref = React.useRef<RTCPeerConnection | null>(null)
    if (pcref.current === null) {
        pcref.current = new RTCPeerConnection()
    }

    useEffect(() => {

        // other stuff...
        pcref.current.ontrack = (e) => {
            const vid = document.getElementById('myvid')
            vid.srcObject = e.streams[0]
        }

        // RTC signalling send SDP offer, get SDP answer, etc.

        // Any cleanup activities (like closing the RTCPeerConnection) can be done here.
        return () => {
            pcref.current.close();
        };
    }, []); // Empty dependency array means the effect will run only once, similar to componentDidMount.

    return (
        <div className="cssclass">
        <video id='myvid' autoPlay muted controls />
        </div>
    );
}

Now, this will work, because the useEffect() function is called after the control is rendered.

But is this the cleanest way forward?

I don't like using useEffect() if it's not necessary, because it ever so slightly complicates things.

Maybe there is another approach using the old fashioned useState() hook.

function MyRtcViewer() {
    const [stream, setStream] = useState(null);
    
    const pcref = React.useRef<RTCPeerConnection | null>(null)
    if (pcref.current === null) {
        pcref.current = new RTCPeerConnection()
        pcref.current.ontrack = (e) => setStream(e.streams[0]);
    }


    

    // Handle your RTC signalling here

    return (
        <div className="cssclass">
        <video
            {...(stream ? { srcObject: stream } : {})}
            autoPlay
            muted
            controls
        />
        </div>
    );
}

In this approach, the video element will be rendered whether stream is null, or non-null. But only when stream is non-null will the srcObject attribute be assigned to the video element.

And that, without the asynchronous useEffect() callback, to me, is the simplest and cleanest approach!