23 min read

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 confusionThe 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

ToolWhat it answers
React DevTools → ComponentsWhat props, state and hooks does this component have right now? Which component rendered it? (You can edit state live.)
React DevTools → ProfilerWhich 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 DevToolsThe 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 terminalBundler 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.

InvestigationTime spent: 0 min
Symptom
After a refactor, the orders page freezes briefly on load. The API logs show one browser sending hundreds of GET /api/orders requests per minute. The console shows a red warning.
1What do you check first?

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.

javascript
// 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 = value on 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.

#
Request for "rea"
Request for "react"
On screen
1
sent at 0 ms
input: rea
1 / 6

Fix.

javascript
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.

HypothesisCheap checkResult
The delete removes the wrong item from stateInspect the state array in React DevTools after deletingThe array is correct — row 1 is gone
The rows are keyed by array indexSearch 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.

  1. Mutation. items.push(newItem); setItems(items) passes the same array back. React compares the new state with the old using Object.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.
  2. State is a snapshot. setCount(count + 1); console.log(count) logs the old value — count is a constant for this render; the new value arrives on the next render. Calling setCount(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 saysCauseFix
Every row rendered because parent renderedThe input's state lives at the top of the pageMove the state down into the component that needs it, or pass the table already-rendered children
Rows rendered though React.memo'd — props changedAn inline object or function prop (style={{…}}, onClick={() => …}) is new each renderuseMemo / useCallback for props passed to memoised children
Many unrelated components rendered — context changedA context provider's value={{ user, theme }} is a new object every renderMemoise the value, or split the context so fast-changing values don't share it with slow ones
One component takes 120 msExpensive 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.

InvestigationTime spent: 0 min
Symptom
A React Native app loads data fine on the iOS simulator. On the Android emulator, every API call fails with "TypeError: Network request failed". The API runs on the developer's laptop.
1What do you check first?

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:

DifferenceHow it crashesCheck
Unhandled JS errors kill the appIn dev, LogBox shows a red screen; in release, an uncaught JS exception terminates the appReactNativeJS lines in logcat; add an error boundary and a crash reporter so you see it
Configuration missing in the release scheme/flavorAn env value (API URL, key) set only for debug is undefined in release, and code crashes on itPrint 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 / NoSuchMethodErrorAndroidRuntime lines; add the library's keep rules
Native module not installed in this buildAn 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 pathsLogic behind __DEV__, or a dev-only polyfill or library, is missing in releaseSearch 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.

EvidenceCauseFix
JS FPS drops while scrolling, UI FPS fineToo much JavaScript per frame: every row re-rendering, heavy renderItemMemoise row components, stable keyExtractor, getItemLayout for fixed-height rows, or a faster list (e.g. FlashList)
Both drop; huge imagesDecoding full-size images for thumbnailsServe resized images; use a caching image component
JS thread busy even when idleconsole.log in hot paths (it is costly in release too), timers, pollingStrip logs in release builds; profile what's running
Animations stutter when JS is busyAnimations driven from the JS threaduseNativeDriver: 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

CaseThe hidden assumptionWhat 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

Self-test: predict each consequence before revealing it.
  • 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 setState doesn'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

TermWhat it means
RenderReact calling your component function to compute the UI for the current state
CommitReact applying a render's result to the screen (DOM or native views)
Referential identityWhether two values are the same object (Object.is), not just equal contents
Dependency arrayThe values an effect, useMemo or useCallback re-runs for, compared by identity
Stale closureA function that captured an earlier render's values and keeps using them
Functional updatesetX(prev => next) — computes the new state from the latest value
Effect cleanupThe function an effect returns; runs before the effect re-runs and on unmount
keyReact's identity for a list item across renders
StrictModeA development-only wrapper that double-invokes renders and effects to expose bugs
React ProfilerDevTools tool showing what rendered in each commit, how long it took, and why
AbortControllerThe standard way to cancel a fetch
HermesThe JavaScript engine React Native uses by default; ships precompiled bytecode in release
MetroReact Native's JavaScript bundler and dev server
adb logcatThe Android device log, including native crashes and ReactNativeJS output
10.0.2.2The Android emulator's address for the host computer
Cleartext trafficPlain http://; blocked by default on Android 9+ and by iOS App Transport Security
R8 / ProGuardAndroid's code shrinker; can strip classes a library loads by reflection
SymbolicationTurning a minified or native stack trace back into source locations using source maps / dSYMs
OTA updateReplacing an app's JS bundle without a store release
Runtime versionA fingerprint of the native code that decides which OTA updates a binary may receive
JS thread / UI threadReact 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.