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 interesting:
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.