Chapter 14B — Casebook V: React and React Native
Render Loops, Stale Closures, Wrong Keys, Out-of-Order Responses — and the Bugs That Only Happen on a Phone
Part 3: The Casebook. Prerequisites: Chapters 1–10. Useful background: FE-02 — React Performance Internals, JS 05 — Race Conditions, JS 06 — Cancellation and Cleanup.
Why UI Bugs Need Their Own Casebook
The earlier casebooks were about requests, servers, caches and time. React bugs are different in one way that changes how you debug them: the UI is a function of state, re-run many times. Your component function runs on every render. Each run creates new objects, new functions and new closures over that render's values. Most React bugs are one of four confusions about that:
| The confusion | The bug it produces |
|---|---|
| "My object is the same object as last render" | Effects that re-run forever, memoisation that never hits |
| "My callback sees the latest state" | Stale closures — handlers and intervals that read old values |
"State changes immediately when I call setState" | Logic that reads the old value right after setting it; mutations React never notices |
| "Responses arrive in the order I sent requests" | The wrong data on screen after fast typing or navigation |
React Native adds a second dimension: the app is two worlds — your JavaScript (running on the Hermes engine) and native iOS/Android code — and it runs on devices, emulators and release builds that differ from your dev setup in ways a browser never does.
The instruments
| Tool | What it answers |
|---|---|
| React DevTools → Components | What props, state and hooks does this component have right now? Which component rendered it? (You can edit state live.) |
| React DevTools → Profiler | Which components rendered in each commit, how long each took, and — with "Record why each component rendered while profiling" enabled in its settings — why: props changed, state changed, hooks changed, or parent rendered |
| "Highlight updates when components render" (DevTools setting) | A flash around everything that re-renders — the fastest way to see "typing in one input re-renders the whole page" |
<StrictMode> (development only) | Renders components twice and runs every effect setup → cleanup → setup on mount, to expose impure renders and missing cleanups |
| The Network panel (Chapter 5) | The same request firing in a loop, requests resolving out of order |
| React Native DevTools | The default debugger in modern React Native (since 0.76): console, breakpoints, React Components and Profiler for the Hermes engine. Older guides mention Flipper or Chrome "remote JS debugging" — both are retired |
| Metro's terminal | Bundler errors and JS console output in development |
adb logcat (Android) / Xcode console or Console.app (iOS) | Native logs and crashes — the only place a release-build or native crash shows up on your machine |
| Crash reporter (Sentry, Crashlytics, Bugsnag) | Crashes from real users' devices — readable only if you upload source maps (JS) and dSYMs / mapping files (native) for each release |
Case 1 — "The Page Freezes and the API Gets Hammered"
Symptom. After a refactor, the orders page freezes for a second, then shows the data. The backend team reports the same client sending hundreds of GET /api/orders requests per minute.
What an interviewer is testing: that you know dependencies are compared by identity, and that you find the loop from evidence (the warning, the Network panel) instead of adding memoisation everywhere and hoping.
Case 2 — "The Timer Is Stuck at 1"
Symptom. A "seconds on this question" timer shows 1 and never moves. A chat screen's WebSocket handler appends new messages to an empty list, wiping the conversation.
// Timer
const [seconds, setSeconds] = useState(0)
useEffect(() => {
const id = setInterval(() => setSeconds(seconds + 1), 1000)
return () => clearInterval(id)
}, []) // runs once
// Chat
useEffect(() => {
socket.on('message', (m) => setMessages([...messages, m]))
}, [])
First five minutes. Open React DevTools → Components, select the component, and watch the state: seconds goes to 1 and stays. Put a breakpoint inside the interval callback: seconds is 0 every single time.
Root cause — a stale closure. The effect ran once, during the first render, and the callbacks it created captured that render's seconds (0) and messages ([]). Every tick computes 0 + 1. Every message is appended to the empty list from the first render. The values are not "stale" because React is slow — each render has its own constant values, and these callbacks belong to render #1 forever.
Fix.
- Use the functional update when the new state depends on the old:
setSeconds(s => s + 1),setMessages(prev => [...prev, m]). React passes the latest value. - If the callback needs the latest value of something it doesn't set, keep it in a ref (
latestRef.current = valueon each render) and read the ref. - Clean up subscriptions: the chat effect above never calls
socket.off, so every remount adds another listener — duplicate messages next (Case 6).
What an interviewer is testing: whether you can explain why the value is old (closures capture a render), not just recite "use the callback form."
Case 3 — "Search Shows Results for the Wrong Query"
Symptom. Type "react" quickly in a search box: the input says "react", the results are for "rea". It happens on slow networks, never on localhost.
Fix.
useEffect(() => {
const controller = new AbortController()
fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch(err => { if (err.name !== 'AbortError') setError(err) })
return () => controller.abort() // runs before the next effect, and on unmount
}, [query])
Where you can't abort (a library call), use an ignore flag set in the cleanup. Data-fetching libraries (TanStack Query, SWR) do this for you by keying results to the query. The same bug appears on fast navigation: open order 1, then order 2 — order 1's slow response lands on order 2's page.
What an interviewer is testing: that you recognise an ordering race from "only on slow networks", and fix it at the source (cancel / ignore) rather than with a debounce that just makes it rarer. More in JS 05 and JS 06.
Case 4 — "Deleting a Row Clears the Wrong Input"
Symptom. A list of editable rows. Type a note into row 3, delete row 1 — the note now appears in a different row, and a checkbox state jumps between items.
Hypotheses.
| Hypothesis | Cheap check | Result |
|---|---|---|
| The delete removes the wrong item from state | Inspect the state array in React DevTools after deleting | The array is correct — row 1 is gone |
| The rows are keyed by array index | Search the JSX for key={index} (or key={i}) | Confirmed |
Root cause. React uses key to decide which component instance is which between renders. With key={index}, after deleting row 1 the old "row 2" now has key 1, so React reuses the instance that used to be row 1 — with its local state (the typed note, an uncontrolled input's value, a checkbox) — for different data.
Fix. Key by a stable, unique ID from the data (key={item.id}). Index keys are only safe for lists that never reorder, insert or delete. The same mechanism is a feature: changing a component's key on purpose (<Form key={userId} />) resets all its state when the user changes.
What an interviewer is testing: that you know what key actually does — identity across renders — not just that "React warns without it."
Case 5 — "I Called setState and Nothing Happened"
Symptom. Clicking "Add" doesn't update the list. Logging right after setCount(count + 1) prints the old number.
Two separate root causes, same complaint.
- Mutation.
items.push(newItem); setItems(items)passes the same array back. React compares the new state with the old usingObject.is, sees the same reference, and skips the re-render. The data changed; React was told nothing changed. Fix: create a new value —setItems([...items, newItem]),setUser({ ...user, name }). In DevTools, the state does show the new item (it's the same array), which is the clue. - State is a snapshot.
setCount(count + 1); console.log(count)logs the old value —countis a constant for this render; the new value arrives on the next render. CallingsetCount(count + 1)three times in one handler adds 1, not 3. Fix: compute the next value once and use it, or use the functional form (setCount(c => c + 1)) when chaining updates.
What an interviewer is testing: immutability and the render snapshot — the two ideas behind most "React is buggy" complaints.
Case 6 — "Every API Call Happens Twice"
Symptom. In development, the Network panel shows each effect's request twice, analytics events fire twice, and a WebSocket connects twice. In production, once.
Root cause. <StrictMode> in development (React 18+) mounts each component, runs its effects, simulates an unmount (runs cleanups), and mounts again. It does this on purpose: an effect that misbehaves when run twice will misbehave in production too — on remounts, fast refresh, route changes, and future React features that keep state while hiding components.
The wrong fix is removing StrictMode. The right fix is making each effect correct when it runs twice:
- Subscriptions and connections: return a cleanup that undoes the setup (
socket.close(),socket.off(...),clearInterval). - Fetches: abort or ignore in cleanup (Case 3) — the duplicate request in dev is harmless once its result is ignored.
- Non-idempotent writes (POST "create order", "send analytics event" on mount): don't trigger them from an effect at all — trigger them from the user's event handler, or make the server idempotent with an idempotency key.
What an interviewer is testing: that you understand double-invocation is a development-only diagnostic, and what "an effect must be resilient to remounting" means.
Case 7 — "Typing in the Search Box Is Laggy"
Symptom. Each keystroke in a filter input takes ~150 ms to appear on a page with a large table.
First five minutes. Turn on "Highlight updates when components render" and type: the whole page flashes, not just the input. Record in the Profiler with "why did this render" enabled.
What the Profiler typically shows, and the fix for each:
| Profiler says | Cause | Fix |
|---|---|---|
| Every row rendered because parent rendered | The input's state lives at the top of the page | Move the state down into the component that needs it, or pass the table already-rendered children |
Rows rendered though React.memo'd — props changed | An inline object or function prop (style={{…}}, onClick={() => …}) is new each render | useMemo / useCallback for props passed to memoised children |
| Many unrelated components rendered — context changed | A context provider's value={{ user, theme }} is a new object every render | Memoise the value, or split the context so fast-changing values don't share it with slow ones |
| One component takes 120 ms | Expensive work during render (sorting/filtering 10k rows) | useMemo on the computation; virtualise the list (render only visible rows); useDeferredValue so typing stays responsive while the table catches up |
What an interviewer is testing: measuring before memoising. "Wrap everything in memo" without a profile is guessing (Chapter 1). Deeper mechanics: FE-02.
Case 8 — "Network Request Failed — Only on Android"
Symptom. A React Native app works on the iOS simulator. On the Android emulator every API call fails with TypeError: Network request failed.
What an interviewer is testing: that "works on iOS, fails on Android" sends you to the differences between the two environments, and that you know JS-side network errors on mobile are vague by design — adb logcat / the Xcode console hold the reason.
Case 9 — "Crashes on Launch — Only in the Release Build"
Symptom. The app runs perfectly in development. The release build (TestFlight / internal testing track) opens and closes immediately. No red error screen.
First five minutes. Install the release build on a device or emulator and read the native log while launching it: adb logcat (filter with adb logcat '*:S' ReactNative:V ReactNativeJS:V AndroidRuntime:E — the quotes stop your shell expanding *), or on iOS the Xcode Devices and Simulators window / Console.app. In production, open the crash in your crash reporter — it is only readable if you uploaded that release's source maps and debug symbols.
What differs between debug and release, ranked by how often it's the cause:
| Difference | How it crashes | Check |
|---|---|---|
| Unhandled JS errors kill the app | In dev, LogBox shows a red screen; in release, an uncaught JS exception terminates the app | ReactNativeJS lines in logcat; add an error boundary and a crash reporter so you see it |
| Configuration missing in the release scheme/flavor | An env value (API URL, key) set only for debug is undefined in release, and code crashes on it | Print the config in a release build; compare build schemes / product flavors |
| Native code minified away (Android R8/ProGuard) | A class a library loads by reflection is stripped: ClassNotFoundException / NoSuchMethodError | AndroidRuntime lines; add the library's keep rules |
| Native module not installed in this build | An error that a native module "could not be found" or "is null" | Was pod install run, and was the native app rebuilt after adding the package? |
| Dev-only code paths | Logic behind __DEV__, or a dev-only polyfill or library, is missing in release | Search for __DEV__ near the failing code |
Reading the stack trace. A release JS stack trace looks like index.android.bundle:1:284731 — minified, one line. Symbolicate it with the source map generated for that exact build (the same idea as Chapter 2's source maps; with Hermes, the bundler and compiler source maps are combined). No matching source map, no readable trace — so uploading them must be part of every release build, not something you do after the incident.
What an interviewer is testing: that you know where release-build evidence lives (native logs and a symbolicated crash report), and that you treat "debug vs release" as a list of concrete differences.
Case 10 — "The Update Broke the App for Some Users"
Symptom. After an over-the-air (OTA) JavaScript update, a subset of users crash when opening the camera screen. Users on the newest store version are fine.
Root cause — version skew between JS and native (the mobile version of Chapter 11's "old frontend, new backend"). OTA updates (e.g. Expo's EAS Update) replace only the JavaScript bundle. The new JS calls a native camera module that was added in the latest store release. Users still on an older binary get the new JS but not the new native code, and the call crashes.
Fix and prevention. Tie every OTA update to a runtime version (a fingerprint of the native code) so a bundle is only delivered to binaries it was built for. Any change to native dependencies requires a new store release, not an OTA update. Track crash rates per app version and per update ID in the crash reporter so a bad update can be rolled back in minutes.
What an interviewer is testing: whether you think about the population of installed versions — on mobile, you never control which version a user is running.
Case 11 — "Scrolling the List Is Janky on Android"
Symptom. A feed scrolls smoothly on a new iPhone and stutters on a mid-range Android phone.
First five minutes. Open the in-app Perf Monitor (dev menu) and scroll: it shows the UI thread and JS thread frame rates separately. Then record with the Profiler in React Native DevTools. Always test performance in a release build on a real low-end device — dev builds are much slower and emulators much faster than users' phones.
| Evidence | Cause | Fix |
|---|---|---|
| JS FPS drops while scrolling, UI FPS fine | Too much JavaScript per frame: every row re-rendering, heavy renderItem | Memoise row components, stable keyExtractor, getItemLayout for fixed-height rows, or a faster list (e.g. FlashList) |
| Both drop; huge images | Decoding full-size images for thumbnails | Serve resized images; use a caching image component |
| JS thread busy even when idle | console.log in hot paths (it is costly in release too), timers, polling | Strip logs in release builds; profile what's running |
| Animations stutter when JS is busy | Animations driven from the JS thread | useNativeDriver: true, or Reanimated, so animation runs on the UI thread |
What an interviewer is testing: the two-thread model — knowing whether the JS thread or the UI thread is the bottleneck before choosing a fix.
The Pattern Behind Every Case
| Case | The hidden assumption | What made it visible |
|---|---|---|
| Render loop | "Same contents means same dependency" | The console warning; Object.is(prev, next) is false every render |
| Stale closure | "My callback sees current state" | Breakpoint in the callback showing 0 every time |
| Wrong search results | "Responses arrive in send order" | Network panel timing; reproducing on a throttled network |
| Wrong row's input | "key is just to silence a warning" | Correct state array, wrong instance — key={index} |
| setState did nothing | "Mutating then setting is a change" | DevTools shows the new item, but no re-render |
| Calls twice | "Effects run once" | Happens only in dev → StrictMode |
| Laggy typing | "Memo everywhere will fix it" | Profiler "why did this render" |
| Android network error | "localhost is my laptop" | Logging the URL; adb logcat |
| Release-only crash | "Release is dev, minified" | Native logs, symbolicated crash report |
| OTA crash | "All users run the same code" | Crashes grouped by app version and update ID |
| Janky list | "It's smooth on my phone" | Perf Monitor on a low-end device, release build |
The Causal Chains
- If you see "Maximum update depth exceeded" or a request firing in a loop
- If a callback always sees the initial value
- If the wrong data shows only on slow networks
- If local state jumps between list items after delete or reorder
- If
setStatedoesn't re-render - If effects run twice only in development
- If the page is slow when typing
- If an Android emulator can't reach your dev API
- If a release build crashes with no red screen
- If crashes appear after an OTA update for some users
The Rule You Keep Forever
In React, every render is a snapshot: new objects, new closures, constant state — debug by asking "which render's values is this code using?" In React Native, the app is JavaScript plus native code on devices you don't control — when the JS error is vague, the native log has the reason, and when only some users break, compare versions.
Vocabulary Map
| Term | What it means |
|---|---|
| Render | React calling your component function to compute the UI for the current state |
| Commit | React applying a render's result to the screen (DOM or native views) |
| Referential identity | Whether two values are the same object (Object.is), not just equal contents |
| Dependency array | The values an effect, useMemo or useCallback re-runs for, compared by identity |
| Stale closure | A function that captured an earlier render's values and keeps using them |
| Functional update | setX(prev => next) — computes the new state from the latest value |
| Effect cleanup | The function an effect returns; runs before the effect re-runs and on unmount |
key | React's identity for a list item across renders |
| StrictMode | A development-only wrapper that double-invokes renders and effects to expose bugs |
| React Profiler | DevTools tool showing what rendered in each commit, how long it took, and why |
AbortController | The standard way to cancel a fetch |
| Hermes | The JavaScript engine React Native uses by default; ships precompiled bytecode in release |
| Metro | React Native's JavaScript bundler and dev server |
adb logcat | The Android device log, including native crashes and ReactNativeJS output |
10.0.2.2 | The Android emulator's address for the host computer |
| Cleartext traffic | Plain http://; blocked by default on Android 9+ and by iOS App Transport Security |
| R8 / ProGuard | Android's code shrinker; can strip classes a library loads by reflection |
| Symbolication | Turning a minified or native stack trace back into source locations using source maps / dSYMs |
| OTA update | Replacing an app's JS bundle without a store release |
| Runtime version | A fingerprint of the native code that decides which OTA updates a binary may receive |
| JS thread / UI thread | React Native's two main threads: your JavaScript, and native rendering and gestures |
Practice Problems
Problem 1 — The loop. A useEffect(() => { load(options) }, [options]) loops, where options is a prop. The parent renders <Orders options={{ sort: 'date' }} />. Where is the bug, and give two fixes — one in the parent, one in the child.
Problem 2 — The chat. A chat screen shows each incoming message two or three times after you navigate away and back. Using Cases 2 and 6, explain why, and write the corrected effect.
Problem 3 — The profile page race. Users who click quickly between profiles sometimes see the wrong person's details under the right person's name. Write the reproduction steps you'd use, explain the cause, and write the fix.
Problem 4 — Release only. An Android release build crashes on launch; the debug build is fine. Write the exact first command you run, the three log tags you filter for, and the four most likely causes in order.
Problem 5 — Tell it. Pick one case from this chapter and tell it in the Chapter 15 interview format: symptom, first checks, hypotheses you killed, root cause, fix, prevention — in under two minutes, out loud.
What's Coming Next
The casebook has been about finding causes. Chapter 15, the last, is about everything around that: running an incident while the cause is unknown, communicating while you investigate, choosing mitigation over diagnosis, writing a blameless postmortem that produces real prevention — and telling the story of a hard bug clearly in an interview.