When React state broke a video-call session
There’s a particular kind of bug that humbles you. Not the red-screen-of-death kind. Not the “undefined is not a function” kind. The kind where everything looks correct… and yet the second participant joins the call and can hear you — but can’t see you.
We were building an MVP video-call system using a company SDK built on top of WebRTC.
Stack:
- Next.js (App Router)
- Zustand (global state)
- React Context (distribution layer)
- WebRTC-based SDK (Publisher/Subscriber model)
The SDK provided:
Publisher→ local video streamSubscriber→ remote stream- Session-based pub/sub architecture
Additionally, they had vanilla JS examples that were clean.
One of their React examples used something intriguing:
const publisherRef = useRef(null);
const subscriberRef = useRef(null);They used refs.
We didn’t.
Why did their vanilla JS example go straight to refs?
Because JavaScript developers don’t have the "put everything in state" reflex. Outside React, it’s natural to reach for a variable that can hold an instance across function calls. useRef is that same instinct—a stable slot that survives function re-execution. React engineers often skip it because state feels like the idiomatic home for anything that matters.I believed:
- If something needs to be shared → put it in state.
- If state changes → React updates UI.
- State is predictable.
- SDK objects are just objects.
So we did this:
const [publisher, setPublisher] = useState<Publisher|null>(null);
const [subscriber, setSubscriber] = useState<Subscriber|null>(null);It felt idiomatic. Declarative. Clean. But WebRTC objects are not passive data containers.
They are:
- Long-lived
- Mutable
- Side-effect heavy
- Managing streams, peer connections, listeners
React treats state as a snapshot — not a container. According to the React docs, state variables are more like a photograph of the world at render time. When React re-renders, it produces a new snapshot. That's the contract. ButPublisherandSubscriberfrom the SDK are designed to be mutated in-place — they accumulate peer connections, attach event listeners, track stream state internally. Passing a mutable engine through an immutable snapshot system is asking for a collision.
I treated them as UI states. They were runtime engines.
Here’s how the bug manifested:
- Caller A joins → publisher created
- Caller B joins → subscriber created
- Caller B hears audio
- Caller B does not see video
Intermittent at first. Then frequent.
Our initialization looked like this:
useEffect(() => {
const pub = sdk.initPublisher(videoElementRef.current!);
setPublisher(pub);
}, []);Then elsewhere:
useEffect(() => {
if (!publisher) return;
session.publish(publisher);
}, [publisher]);On paper? Totally fine.
In reality?
- The SDK mutates internal fields.
- React re-renders based on state changes.
- Closures captured stale references.
- Event listeners bound to instances that weren’t stable.
The stale closure trap — made explicit
Here's what actually happened under the hood. The SDK internally does something like this:
// Inside sdk.initPublisher()
this.onStreamCreated = (event) => {
this.videoElement.srcObject = event.stream; // mutates in place
};When setPublisher(pub) triggered a re-render, React created a new render cycle. Any callback or useEffect that had closed over the previous publisher reference was now holding a ghost — the old instance. If the SDK fired an event between renders, the handler bound to the old instance ran, but the new render's publisher was a different object. The two were out of sync.
Why audio worked but video didn't This is the most telling symptom. Audio tracks in WebRTC are attached to the peer connection at the signaling level — once the SDP (Session Description Protocol) offer/answer exchange completes, the audio stream flows independently of React's render cycle. The SDK had already negotiated audio into the connection. For video, however, requires binding aMediaStreamto a DOM element (videoElement.srcObject = stream). That binding happens imperatively, inside a callback that the SDK fires after the publisher is fully initialized. If that callback was bound to a stale instance — one whose internalvideoElementreference was no longer in sync with the mounted DOM node — the assignment silently failed. No error. No warning. Just a black square where your face should be.
We passed a living, mutable engine through React’s state lifecycle. React did exactly what it promises to do: re-render. The SDK did exactly what it promises to do: mutate.
That mismatch created subtle timing issues: the kind that eats half a day.
Revisiting the SDK example:
const publisherRef = useRef<Publisher|null>(null);
useEffect(() => {
publisherRef.current = sdk.initPublisher(videoElementRef.current!);
}, []);And then:
session.publish(publisherRef.current!);No state.
No re-render triggers.
No dependency array dance.
Just identity stability.
That’s when it clicked:
🔑 useRef is not just for DOM nodes. It’s for long-lived mutable instances that must survive renders without participating in reconciliation.
React docs describe this explicitly: "When you want a component to 'remember' some information, but you don't want that information to trigger new renders, you can use a ref." This is exactly that case.State vs Ref comparison
useState | useRef | |
| Triggers re-render | ✅ Yes | ❌ No |
| Value is a snapshot | ✅ Yes | ❌ No (mutable .current) |
| Survives renders | ✅ Yes | ✅ Yes |
| React is aware of changes | ✅ Yes | ❌ No |
| Good for UI model | ✅ Yes | ❌ Not directly |
| Good for imperative instances | ❌ Risky | ✅ Yes |
useRefis an escape hatch from reactivity — and that's a feature, not a workaround. React's reconciliation system is powerful precisely because it is opinionated.useRefis how you say: "this thing lives outside that system, and that's intentional."
React state = declarative UI model
WebRTC publisher = imperative runtime system
Different mental models.
We replaced:
const [publisher, setPublisher] = useState<Publisher|null>(null);With:
const publisherRef = useRef<Publisher|null>(null);
useEffect(() => {
publisherRef.current = sdk.initPublisher(videoElementRef.current!);
}, []);Usage:
const handleJoin = () => {
if (!publisherRef.current) return;
session.publish(publisherRef.current);
};Don't forget cleanup — especially with WebRTC. This was a second lesson hiding inside the first. WebRTC objects hold open peer connections, media tracks, and event listeners. If you unmount the component without tearing them down, you leak all of it. The fix isn't complete without a cleanup return:
useEffect(() => {
publisherRef.current = sdk.initPublisher(videoElementRef.current!);
// Cleaning up the publisher
return () => {
publisherRef.current?.destroy(); // SDK teardown
publisherRef.current = null;
};
}, []);
useEffect(() => {
subscriberRef.current = sdk.initSubscriber(remoteVideoRef.current!);
// Cleaning up the subscriber
return () => {
subscriberRef.current?.unsubscribe();
subscriberRef.current = null;
};
}, []);Without this, navigating away from the video call page leaves ghost peer connections alive, camera indicators still lit, and memory accumulating.
Result:
- No unnecessary re-renders
- No stale closures
- Stable object identity
- Bug gone
Consistently.
- State implies UI participation.
- SDK engines don’t need reconciliation.
useRef gives you:
- Stable reference
- No re-render trigger
- Same instance across renders
That matters for:
- WebRTC
- WebSockets
- Media streams
- Canvas contexts
- Any imperative SDK
- React is declarative.
- WebRTC is imperative.
- Forcing one mental model onto the other creates friction.
- Sometimes the senior move is knowing when not to use state.
- The docs explain what hooks do.
- Experience teaches when not to use them.
- Senior engineering isn’t about memorizing APIs.
- It’s about understanding lifecycle boundaries.
The lesson isn't "never use state with SDK objects." It's "don't store mutable engine instances in state." You will almost always need UI-facing state alongside your refs.
For example, you still need to know whether the publisher is active or how many participants are connected or what the connection quality is. That's UI-relevant information — and it belongs in state. The pattern is:
// Ref: the SDK instance — stable, not reactive
const publisherRef = useRef<Publisher | null>(null);
// State: UI-relevant values derived from SDK events — reactive
const [isPublishing, setIsPublishing] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected'>('idle');
useEffect(() => {
publisherRef.current = sdk.initPublisher(videoElementRef.current!);
publisherRef.current.on('streamCreated', () => {
setIsPublishing(true); // ← this drives the UI
setConnectionStatus('connected');
});
publisherRef.current.on('streamDestroyed', () => {
setIsPublishing(false);
setConnectionStatus('idle');
});
return () => {
publisherRef.current?.destroy();
publisherRef.current = null;
};
}, []);The SDK instance lives in a ref. The values you surface to the UI come from state, driven by SDK events. The two layers are cleanly separated. React owns what it's supposed to own. The SDK owns what it's supposed to own.
The fix was small. The lesson wasn’t.
I overestimated React state’s flexibility and underestimated how aggressive reactivity can clash with imperative systems.
As frontend engineers, we’re not just writing UI anymore.
We’re orchestrating:
- Media engines
- SDK lifecycles
- Render cycles
- State managers
- Real-time systems
A rule of thumb for integrating any imperative SDK into React: Reach foruseReffirst for the SDK instance. Then layeruseStateon top only for the values you want the UI to respond to. This separation of concerns isn't a workaround — it's the architecture.
Sometimes the right move isn’t “more state.”
Sometimes it’s stability.
Sometimes it’s just a ref.
And sometimes the bug that humbles you quietly upgrades your mental model.
References: