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.
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
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.
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 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);
};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 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
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.