This article is published in English.
React Native Trade-offs at Scale: State, Lists, Devices, Tokens, Upgrades
Five React Native problems that expose engineering judgment: server vs client state, laggy FlatLists, low-end Android, token storage and incremental upgrades.
Building a screen in React Native is easy. The hard part comes as the app grows: state spreads everywhere, lists stutter, budget Android phones crash, tokens sit in plaintext and the framework drifts years behind. Below are five such situations, often used to probe senior engineers in interviews, with the reasoning a strong answer contains, so you can apply it to your own codebase.
Separating server state from client state
When local state, API responses, caches and shared UI flags have grown into one tangle, the first question is not "Redux or Context?" but "who owns this data?"
Data from an API, such as profiles, feeds or product lists, is server state. It goes stale and must be refetched, cached and invalidated. Pushing it into a global store means reimplementing all of that by hand, plus loading and error flags. The snippet below shows that anti-pattern.
// ❌ Server data forced into a global store —
// now YOU own caching, staleness, invalidation, loading states…
dispatch(setProducts(await fetchProducts()));
TanStack Query and RTK Query exist to own this layer. Here useQuery keys data by category and keeps it fresh for 60 seconds via staleTime. The second half of the block (merged into one in the source) shows what is left for a global store: a small session object.
// ✅ Server state managed by a tool built for it
const { data, refetch } = useQuery({
queryKey: ['products', category],
queryFn: () => fetchProducts(category),
staleTime: 60_000,
});// ✅ Truly shared client state — small and intentional
const useSession = create<SessionStore>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
The remaining problem is much smaller. Form inputs, toggles and animation values stay in the component, where they are cheap, isolated and vanish with the screen. A global store should hold only data that several screens need and that the client itself owns, for example the signed-in user, the theme, feature flags or UI state spanning screens.
What breaks when everything is global
Updates re-render unrelated screens, every feature couples to the store's shape, tests must mock the world, and refactors become excavation. A bloated store looks like architecture but behaves like debt.
Diagnosing a laggy FlatList without guessing
A FlatList feels sluggish although the API is fast. Sprinkling React.memo everywhere is guessing; the disciplined move is to measure first.
Profile with React DevTools (or the why-did-you-render library) while scrolling. When all rows re-render on every scroll tick, the cause is referential identity: inline arrow functions in renderItem, style objects rebuilt per render, or no keyExtractor, which forces remounts. Here each render creates a new onPress closure and style object, so no row can skip rendering.
// ❌ New function + new object on EVERY render → every row re-renders
<FlatList
data={items}
renderItem={({ item }) => (
<Row item={item} onPress={() => open(item.id)} style={{ padding: 12 }} />
)}
/>
The fix gives React stable references: a memoized Row, a useCallback-wrapped renderItem, a stable key, and getItemLayout so the list never measures rows. The typed props make this TSX.
// ✅ Stable identities + memoized rows
const Row = React.memo(({ item, onPress }: RowProps) => { /* … */ });const renderItem = useCallback(
({ item }: ListRenderItemInfo<Item>) => <Row item={item} onPress={handlePress} />,
[handlePress],
);<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
/>
getItemLayout only suits fixed-height rows; wrong values for variable heights cause jumps and blank gaps.
Reading the Perf Monitor
If renders look fine but frames still drop, compare the two Perf Monitor counters:
- Low JS FPS means the JavaScript thread is overloaded, typically by a heavy
renderItemor an unthrottledonScroll. - Low UI FPS points to native cost, usually images. Decoding full-resolution photos into 80pt thumbnails wastes memory and frames; resize server-side or use
expo-imageorFastImagewith proper dimensions.
Only then tune the list with windowSize, removeClippedSubviews, or a move to FlashList. Tuning list props before profiling rows treats the symptom.
Catching crashes on budget Android devices
An app that is smooth on a flagship can crash on the cheap Android phones most users own. Start with data: the Play Console or analytics show your real device mix, which rarely matches your team's phones.
Make low-end hardware part of daily work: keep a physical device with 2 to 3 GB of RAM nearby, or run Firebase Test Lab against the models from your analytics, as this command does.
gcloud firebase test android run \
--app app-release.apk \
--device model=a10,version=29 \
--device model=redmi9,version=30 # the phones in your analytics, not yours
Always test release builds. Debug builds hide real performance, and Hermes in release behaves differently from the debug runtime. Typical failures on weak hardware are memory pressure from big images or retained lists, an overloaded main thread, and out-of-memory kills that never happen on high-end phones.
Degrading gracefully instead of crashing
You can adapt the experience per device. react-native-device-info reports total memory.
import DeviceInfo from 'react-native-device-info';
With it you can flag devices under 3 GB as low-end, serve thumbnails instead of full images, and skip blur, parallax and heavy animation. Since getTotalMemory is async, compute the flag once at startup rather than awaiting during render.
const totalMemory = await DeviceInfo.getTotalMemory();
const isLowEnd = totalMemory < 3 * 1024 ** 3; // < 3 GB RAM<Image
source={{ uri: isLowEnd ? item.thumbUrl : item.fullResUrl }}
// skip blur, parallax and heavy animations on low-end devices
/>
Then ship defensively: segment Sentry or Crashlytics reports by device tier, use staged Play Store rollouts (5%, 20%, 100%), and halt before a bad build reaches everyone. The aim is not zero crashes but catching them early and cheaply.
Storing auth tokens safely
AsyncStorage writes data unencrypted to disk: an SQLite file on Android, plain sandbox files on iOS. A rooted or jailbroken device, a malicious backup or filesystem access exposes tokens directly. It was built for preferences, not secrets.
Tokens belong in hardware-backed storage, the iOS Keychain and Android Keystore, which react-native-keychain wraps.
import * as Keychain from 'react-native-keychain';
This call stores the serialized tokens with WHEN_UNLOCKED_THIS_DEVICE_ONLY: readable only while the device is unlocked and never migrated to another device.
await Keychain.setGenericPassword('auth', JSON.stringify(tokens), {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
A refresh flow that survives concurrent 401s
Give access tokens a lifetime of minutes rather than days, back them with a refresh token that is replaced every time it is exchanged, and centralize this in one auth layer that refreshes exactly once however many requests fail together. A shared promise makes that possible.
let refreshing: Promise<string> | null = null;
The Axios interceptor rethrows anything but a 401. For a 401, ??= starts a refresh only if none is in flight, so concurrent failures await the same promise; finally resets it, and the original request is retried with the new bearer token. The source squeezes several statements onto single lines. For a deeper treatment, see why concurrent 401s log users out and the single-flight refresh fix.
api.interceptors.response.use(undefined, async (error) => {
if (error.response?.status !== 401) throw error; // Concurrent 401s all await the SAME refresh — no refresh storm
refreshing ??= refreshTokens().finally(() => (refreshing = null));
const newToken = await refreshing; error.config.headers.Authorization = `Bearer ${newToken}`;
return api.request(error.config); // retry the original request
});
Two more details: do not hold tokens in JS-accessible global state longer than needed, add certificate pinning for truly sensitive APIs, and never log tokens, since crash reporters happily capture request headers. "Use SecureStore" names a tool; a strong answer covers lifetime, refresh and failure modes.
Upgrading a two-year-old React Native app
The app is two years behind, downtime is not allowed and there is no rewrite budget.
Never leap to the latest version. The React Native Upgrade Helper shows the exact diff between versions; move one or two minor versions per step, keeping the app buildable and shippable throughout. Each hop is an ordinary release, which is how you get zero downtime.
Audit dependencies first
Upgrades die on old, unmaintained native libraries, not on React Native itself. Before the first hop, identify dependencies that block the New Architecture or newer Gradle and Xcode requirements, and replace or fork abandoned ones.
Automate verification of every hop
Set up end-to-end smoke tests for critical flows before starting, so each hop is verified in minutes instead of by manual QA. This Maestro flow logs in with an email from an environment variable and checks that home, cart and checkout are reachable.
# smoke-test.yaml — run with Maestro on every upgrade hop
appId: com.myapp
---
- launchApp
- tapOn: "Log in"
- inputText: ${EMAIL}
- tapOn: "Continue"
- assertVisible: "Home"
- tapOn: "Cart"
- assertVisible: "Checkout"
Pitch the work to the business as risk reduction, not refactoring: each version you fall behind raises the cost of the next forced upgrade driven by store rules, OS deprecations and security patches. Small hops turn a scary project into a string of boring releases, and boring is the goal.
Key takeaways
- Decide who owns each piece of data; let a query library own server state and keep the global store small.
- Profile before optimizing: identity issues, JS-thread work and image decoding need different fixes.
- Test release builds on your users' real devices and roll out in stages.
- Treat tokens as a system: secure storage, short lifetimes, rotation and single-flight refresh.
- Upgrade in small, shippable steps backed by automated smoke tests.