•
4 min read
the list header remount that broke contact search
react-native debugging apollo

Nobody assigns you production ownership of a mobile app. You either watch the session replays or wait for the support tickets. I watch them, and over one stretch the replays showed three distinct crash classes in the React Native app, each showing up as symptoms that did not look related. Fixing the classes rather than the symptoms cut the mobile crash rate by about 60%, measured in the same tool before and after.

the flatlist header that remounted

The one worth telling slowly. Contact search on a list screen broke intermittently: type a query, the list updates, and then the search text vanished and the list reset. Support saw it as “search does not work sometimes”. The replays showed exactly what the user did and exactly when the state reset, which narrowed it to one component boundary.

The search input lived in the list’s header. In React Native’s FlatList, the header is a ListHeaderComponent prop, and the way it was passed created a new component type on every render of the parent:

<FlatList
  ListHeaderComponent={() => <SearchHeader value={query} onChange={setQuery} />}
  ...
/>

An inline arrow function is a new function identity each render. React treats a different function as a different component type, unmounts the old header, and mounts a new one. The new header’s input starts empty. Every parent re-render, including the one triggered by the list data arriving, reset the search.

The fix is to pass an element or a stable reference:

const header = useMemo(() => <SearchHeader value={query} onChange={setQuery} />, [query]);
<FlatList ListHeaderComponent={header} ... />

Same header instance across renders, state preserved, search works. The pattern is documented, and it is still one of the most common React Native mistakes because the broken version renders correctly on the first paint and only fails on the second.

dangling cache references

Apollo’s normalized cache stores objects by type and id, and lists hold references to them. Evict an object, and any list still holding its reference now points at nothing. On web, a component reading that reference gets undefined and usually renders a blank. On React Native, the same undefined reached a native component prop that required a value, and the app crashed.

The evictions came from a cleanup path that removed entities after a delete mutation without also updating the lists that referenced them. The fix is the one Apollo documents and everyone skips: after cache.evict, call cache.gc, and in the mutation’s update function remove the reference from every list that held it, rather than leaving dangling pointers for the garbage collector to find later. Once the reference removal was in the same update as the eviction, the class went away.

no-cache fetch-policy races

A no-cache fetch policy tells Apollo to fetch from the network and never write the result into the store. Several screens used it for data that was considered too volatile to cache. Two of them could be on the navigation stack at once, both fetching the same entity, one through the cache and one bypassing it.

The race: the cached screen updates the store, the bypassing screen holds a stale copy it fetched separately, the user navigates back, and a component receives an object whose shape no longer matches what the store now says its type looks like. Sometimes that was a blank field. Sometimes it was a crash on a property access.

The fix was to stop using no-cache for anything that another screen also read through the cache, which was most of the places it had been added. network-only gets the freshness those screens wanted and still writes the result to the store, so every reader sees one copy.

why replays and not logs

All three classes produced crash logs. None of the logs said what the user had done. A stack trace from a FlatList render says nothing about a search input, and a null property access on a contact says nothing about a delete mutation on a previous screen. Session replay shows the sequence, and the sequence is the root cause.

The number is the crash rate before and after the three fixes, in the same replay and monitoring tool. The mechanism is what makes it believable: three remount, eviction, and fetch-policy bugs, each of which was firing on a common user path, would account for most of a mobile app’s crashes on their own.