Earlier this year, our company organized an engineer offsite, and my coworker M brought a book with him.
I was very judgemental at the beginning (there are so many articles and posts out there about debugging, so why do we need a book, published in the early 2000s, by an author born in the 50s?). But the book turned out to be surprisingly good, so much so that I’d spend an hour reading a chapter each evening after a whole day’s intensive work while others were drinking.
Since then I’ve long been hoping to write my own debugging experiences that mirror the rules the author offered. But procrastination came in the way. Until 2023. Finally here came the first post, about understanding the system.
React: Audio/Video Layer
At work, our application is a web based virtual event platform. The actual specification doesn’t matter here, we just need to render the page properly based on the state of the channel: how many users (we call entities) are connected to the channel, each entity’s state (are they publishing or muting their audio/video, am I already subscribed to their audio/video feed or am I still subscribing), and we need to handle users’ actions: they join channel, they try to publish their camera video feed or they try to subscribe to some other user’s video feed.
Since our application is implemented in React, we naturally chose to use Context to implement an abstraction layer. The code looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
import * as React from "react"; import { produce } from "immer"; const _Context = React.createContext(null); function AVChannelService() { const [state, setState] = React.useState({}); const produceState = f => setState(produce(f)); const avcs = { async connect(channelId, entityId, authToken) { // omitted: check user isn't connected to channelId yet produceState(draft => { draft[`#{channelId}:#{entityId}`] = { connStatus: "connecting", }; }); await VideoProviderSDK.joinChannel(channelId, entityId, authToken); produceState(draft => { draft[`#{channelId}:#{entityId}`] = { connStatus: "connected", }; }); }, connStatus(channelId, entityId) { const channelSessionKey = `#{channelId}:#{entityId}`; return state[channelSessionKey] ? state[channelSessionKey].connStatus : "unconnected"; }, // omitted: publishing, subscribing, etc. } return ( <_Context.Provider value={{ avcs }}> {children} </_Context.Provider> ); } export function useAVChannelService() { const avcs = React.useContext(_Context); if (!avcs) { // omitted: error handling } return avcs; } |
And in an actual component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
export function SomeComponent(channelId, entityId, authToken) { const { avcs } = useAVChannelService(); if (avcs.connStatus === "unconnected") { return ( <SomeParent> <button onClick={ async () => { await avcs.connect(channelId, entityId, authToken); } } /> </SomeParent> ); } else if (avcs.connStatus === "connecting") { return ( <span>Connecting...</span> ); } else { return ( <SomeParent> <span>Connected</span> // omitted: other entities in the channel </SomeParent> ); } } |
Since my background was mostly backend when I took over this project, it took me a while to fully understand the logic, specifically the react component lifecycle, and convince myself the implementation is correct. Confusions I’ve encountered are:
- Is it possible that multiple components get inconsistent (e.g., X thinks we are still connecting, while Y thinks we are connected)?
- Will the two state mutation, on line 12 and line 18, have any kind of race condition, since there is an
async
invocation in between? - If I need to keep track of something else, can I simply do an extra
const [x, setX] = React.useState(), and mutate X intermittently with
produceState
? Will any component read a up-to-date value of one state but a stale value of the other?
Luckily, around that time, M gave a few tech talks about frontend and React, and combined with some readings, I gained the understanding sufficient enough to reason the concept and the implementation.
First of all, React has well defined lifecycle for components. If X and Y are under the same AVChannelService
parent, such inconsistency will never happen that X is rendered, then the state gets updated, then Y is rendered. The state setting won’t happen in between the rendering of two child components. So this clarifies my first confusion. (Side note: another post this lifecycle post links to gives a more complete comparison if you implement a full “app” with or without hooks, which conveys far more than the typical Counter example the official doc and many other posts like to use.)
What about mutating the state before and after an async
function? We know that states are not updated immediately but rather are batched. Things after await
either happens within the same batch if the future resolves fast enough, or happens in the next batch. (Side note: will things after an await
always happen in the next tick? I’m not sure. But it doesn’t affect the correctness here.) Either way, there’s no race condition. You just need to avoid reading a state value somewhere after the set
. (Sometimes, you may want to break down multiple updates into different effect
s, instead of everything in one block, like this gist. But this topic is worth another whole post.)
Finally what about multiple pieces of states? Similar to the previous paragraph, React won’t pause the execution of a function to do perform rendering. (If we don’t use the await
keyword at all, but use explicit then
callbacks everywhere, things will be more clear. The callbacks will be added to a queue, thus the execution of a callback is some nondeterministic time in the future, but other sequential code is executed in order without interruption.)
Summary
Well, this isn’t a specific “bug” per se. But just writing the code and being able to reason about it gives me more satisfaction than lots of bugs I’ve resolved. Just having this understanding of the system makes me write code with confidence and avoid many bugs in the first place.
P.S.
There is some information that while not immediately useful offers more intuition. Like the functional reactive programming (M even explained GUI application in the early days), and how React borrows and differs from this idea.
And there are still something related to the internal implementation and optimization that I haven’t sorted out (like reconciliation, virtual DOM, React event loop / native JS event loop), but I’m happy for now.
And my usual complaint: there are many tutorials out there (like this), but not many good ones. Most give examples too simple to convey the motivation of the technique, and lack a high level overview of the design of the system, not to mention the history or intuition behind it.