RN

Study the fundamentals

React Native & Expo Lessons

25 lessons across 5 topics — core rendering, the Expo ecosystem, state & data, navigation, and performance. Each lesson explains the why, not just the what. Read from top to bottom or jump to the category you need.

25
lessons
5
categories
JR–SR
levels covered

RN CORE

How React Native Renders: Bridge, JSI, and Hermes

The thread model and execution architecture that explain every RN performance rule.

level.junior

Every React Native performance rule — don't block the JS thread, don't do heavy sync work in a render, offload animations to native — flows from one fact: your JavaScript runs on a thread that is separate from the UI thread and can only do one thing at a time. Get that mental model wrong and every profiling session turns into guesswork.

In the old architecture (pre-0.68), JS and native communicated via a JSON bridge — serialising every message to JSON, crossing a thread boundary, and deserialising on the other side. This was the root cause of most RN performance problems: any heavy JS work blocked the bridge and caused dropped frames.

The New Architecture (Fabric + JSI) replaces the bridge with JavaScript Interface (JSI) — a C++ layer that lets JS hold direct references to native objects and call native functions synchronously, without serialisation. Fabric is the new renderer: it builds the component tree in C++ and can calculate layout on any thread. Together, they eliminate the async-only constraint.

Hermes is Meta's JavaScript engine optimised for React Native. Unlike V8 or JavaScriptCore, Hermes pre-compiles JS to bytecode at build time, so startup is faster and memory usage is lower. It ships as the default engine from RN 0.70 and is required for the New Architecture. You can verify it's running with HermesInternal !== undefined.

Three threads you care about: JS thread (runs your React code, state updates, effects), UI thread (renders native views, handles gestures), Shadow/Layout thread (Yoga layout calculations). Keep the JS thread free of synchronous work and long loops — that's the rule every performance optimisation flows from.

Red flag: saying JSI makes native calls "free." It removes JSON serialisation, but a call across the boundary still costs a function call and, for non-primitive data, a memory copy — the JS thread is still single-threaded and still your bottleneck.

"New Architecture removes the JSON bridge, but the JS thread is still single-threaded — every performance decision I make starts with keeping it unblocked."

Core Primitives: View, Text, ScrollView, and When to Use Each

The handful of native components you build everything from — and their non-obvious rules.

level.junior

React Native doesn't render to the DOM. Every component maps to a native widget. Knowing which primitive to reach for — and its constraints — is the first skill.

  • <View> — the generic container. Maps to UIView on iOS and android.view.View on Android. Supports flexbox layout, touch event handlers, and style. Use it the way you'd use a <div>.
  • <Text> — all text must be wrapped in <Text>. Text nodes outside it will throw in development. <Text> inside <Text> inherits style (unlike <View>), which is useful for inline bold/italic.
  • <Image> — requires explicit width and height or the image won't render. For remote images, contentFit replaces the old resizeMode prop in Expo Image.
  • <ScrollView> — renders all children eagerly. Fine for short lists; for long ones use <FlatList> (virtualised) instead. Never nest a <FlatList> inside a <ScrollView> with the same scroll axis — the inner list won't scroll.
  • <TextInput> — the mobile keyboard input. Always pair with onChangeText and a state variable. keyboardType, returnKeyType, autoCapitalize, and secureTextEntry are the most-reached props.
  • <Pressable> — the modern touchable. Replaces TouchableOpacity and TouchableHighlight. Accepts a function child to apply pressed-state styles: style={"{"}({"{"}pressed{"}"}) => pressed && styles.pressed{"}"}.

Red flag: nesting a <FlatList> inside a <ScrollView> that scrolls the same direction — the inner list either won't scroll or both fight for gesture ownership. Flatten to one scroll container.

"Every element I render maps to a real native view, not a DOM node — so I pick the primitive by its native behaviour and constraints, not just its visual result."

StyleSheet.create and the Layout System

Why StyleSheet.create isn't optional, and how Yoga/flexbox in RN differs from the web.

level.junior

StyleSheet.create exists because sending a style object across to native on every single render is wasted work — it registers the object once and hands the native thread a numeric ID instead. Skip it and you pay that cost repeatedly for no visual benefit.

React Native uses Yoga — a cross-platform Flexbox layout engine — instead of the browser's CSS engine. Most flexbox properties work the same, but a few differ: flexDirection defaults to 'column' (not row), there's no display: grid, no CSS cascade, and units are density-independent pixels (no px, em, or % except in a few specific props).

StyleSheet.create({"{"}{"{"}…{"}"}{"}"} is not just a naming convention. In development it validates property names and values immediately. In production, it registers the style object once and sends a numeric ID to the native thread instead of the full object on every render — reducing bridge traffic (or JSI calls). Inline style objects create new references on every render and bypass this optimisation.

The two layout patterns you'll use most: fill the parent (flex: 1 on a child inside a parent with dimensions) and stack children (parent as a column/row flex container, children with flex or fixed sizes). Absolute positioning (position: 'absolute' + top/right/bottom/left) escapes normal flow, exactly like CSS.

A common gotcha: overflow: 'hidden' clips children on both platforms, but border-radius + overflow clipping behaves slightly differently on Android below API 28. If you need a clipped rounded container on Android, wrap it in a View with the borderRadius applied and use overflow: 'hidden' on that wrapper, not on the inner content.

Red flag: treating StyleSheet.create as pure convention and reaching for inline style objects "because it's simpler" — every inline object is a new reference on every render, so it silently reintroduces the cost the API exists to remove.

"I always define styles with StyleSheet.create, not inline objects — inline styles create a new reference every render and skip the ID-registration optimisation."

Component Lifecycle in React Native (Hooks Edition)

Mount, update, and cleanup — and the RN-specific timing quirks that differ from web.

level.mid

Unlike a web tab, a backgrounded RN app doesn't pause its JS thread — a forgotten interval, subscription, or animation keeps running (and draining battery) after the user leaves the screen. Cleanup discipline in useEffect isn't boilerplate, it's what prevents that.

In function components with hooks, the lifecycle maps to: mount = first render + effects fire, update = re-render when state/props change + effects fire if deps changed, unmount = cleanup functions in useEffect return values run.

useEffect(() => { /* setup */ return () => { /* cleanup */ }; }, [deps]) — the deps array controls when the effect re-runs. Empty array = runs once on mount. No array = runs after every render (rarely useful). Specific deps = runs when those values change. The lint rule react-hooks/exhaustive-deps enforces correct deps — don't suppress it.

RN-specific considerations: AppState events fire when the app foregrounds/backgrounds — subscribe in a useEffect and remove the listener in the cleanup. BackHandler (Android) needs a similar pattern. Forgetting to remove these listeners is a common memory leak.

One important difference from web: the JS thread doesn't pause when the app backgrounds — your code keeps running. If you have a polling interval or animation running, you should pause it on AppState'background' and resume on 'active', both for battery and correctness. React Navigation provides useFocusEffect which triggers when a screen comes into focus within the stack — useful for refreshing data that may have changed while navigating away.

Red flag: suppressing react-hooks/exhaustive-deps to "fix" an infinite loop instead of fixing the dependency — that lint rule is catching a real stale-closure bug, not being pedantic.

"I treat every subscription in useEffect as a leak until it has a matching cleanup — RN doesn't pause the JS thread when the app backgrounds, so an uncleaned listener keeps running."

Platform-Specific Code: Platform.OS, .ios.ts, and Platform.select

Three patterns for writing code that behaves differently on iOS vs Android — and when to use each.

level.mid

React Native apps run on multiple platforms from one codebase. Three tools let you diverge when you need to.

1. Platform.OS inline — use for single-value differences: paddingTop: Platform.OS === 'ios' ? 44 : 0. Simple but scatters platform checks throughout the file. Good for one-off values.

2. Platform.select({"{"} ios: …, android: …, default: … {"}"}) — cleaner for larger objects: StyleSheet.create({"{"} container: Platform.select({"{"} ios: iosStyles, android: androidStyles {"}"}) {"}"}). Returns the matching platform's value at runtime.

3. Platform-specific file extensionsButton.ios.tsx and Button.android.tsx let Metro/Expo bundle the right file automatically. Import as import Button from './Button' and Metro resolves to the correct platform file. Use this when platform implementations diverge significantly — it keeps each file clean.

A fourth option for Expo-managed apps: the Platform.Version check for OS version-specific behaviour (e.g., Platform.Version >= 14 for iOS 14+ APIs). Combine this with Platform.OS for a platform+version guard. For device-capability checks (has notch? has dynamic island?), expo-device provides modelId and screen dimension helpers — don't hard-code pixel cutoffs.

Red flag: hard-coding pixel cutoffs to detect a notch or dynamic island — device dimensions change every hardware generation. Check capability (safe-area insets, expo-device) instead of guessing a screen size.

"I pick the platform-divergence tool by how much the implementations diverge — inline for a single value, Platform.select for a style object, separate .ios/.android files when the logic itself differs."

EXPO SDK

Managed vs Bare Workflow: The Real Trade-off

What Expo's managed workflow actually controls — and when bare is worth the cost.

level.junior

Managed workflow owns the native project so your team doesn't have to maintain ios//android/ directories or keep up with every native RN upgrade by hand — that's the trade-off, not a beginner-only training-wheels mode. Bare workflow hands you the native projects directly in exchange for full native control.

Managed workflow means Expo owns the native project files (ios/ and android/ directories don't exist in your repo). You configure native behaviour through app.json / app.config.js and Expo config plugins. The build runs on EAS Build (cloud) which generates native projects on the fly. You never manually open Xcode or Android Studio for routine work.

Bare workflow means you've run npx expo prebuild (or started with npx react-native init). The ios/ and android/ directories live in your repo and you control them directly. You can use any native library, write native code freely, and customise the build in any way. The cost: you own the native projects. Every React Native upgrade may require manual migration of those directories.

The practical guide: start managed. Yes, that means accepting a real cost — you can't hand-edit Info.plist or drop in an arbitrary native SDK without a config plugin. But the alternative, starting bare, means paying the ongoing cost of manually migrating ios//android/ on every RN upgrade — for most apps, that recurring tax is bigger than the plugin ceiling you'd hit. The managed workflow supports 99% of app features through Expo SDK modules, and config plugins let you extend native behaviour without ejecting. Run npx expo prebuild only when you need something a plugin can't express — and even then, commit the generated files so CI can build them. The old concept of "ejecting" is gone; prebuild is the replacement and it's reversible (you can delete and regenerate the native directories). As the app matures and needs a one-off native module, prebuild is the escape hatch, not a reason to have started bare.

One critical nuance: EAS Build is not optional in managed workflow for production apps. You cannot produce a signed IPA/APK without EAS Build (or a local simulator build with npx expo run:ios). The managed workflow trades local build control for cloud build convenience.

"I default to managed workflow and reach for prebuild only when a specific native requirement outgrows what a config plugin can express — that keeps native-upgrade maintenance off the team until it's actually needed."

Config Plugins: Extending Native Without Ejecting

How Expo plugins work, how to write one, and when you actually need to.

level.mid

A config plugin is a function that runs during npx expo prebuild and modifies the generated native projects. It receives the Expo config and returns a modified version, with hooks to touch AndroidManifest.xml, Info.plist, Gradle files, or any native file the build produces.

Most Expo SDK modules ship with their own config plugin — you just list the module name in the plugins array of app.config.js and prebuild wires the native code. You only write a custom plugin when you need a native change that no existing plugin covers: adding a custom permission, injecting a build flag, writing a new XML file, or setting a specific AndroidManifest attribute.

A minimal plugin structure:

// plugins/withMyFeature.ts
import { ConfigPlugin, withAndroidManifest } from '@expo/config-plugins';
const withMyFeature: ConfigPlugin = (config) =>
  withAndroidManifest(config, async (mod) => {
    mod.modResults.manifest.$['android:usesCleartextTraffic'] = 'false';
    return mod;
  });
export default withMyFeature;

Register it in app.config.js: plugins: ['./plugins/withMyFeature']. Run npx expo prebuild to see the change applied to the generated native files. The generated native directories can be regenerated at any time — treat them as a build output, not a source of truth, and git-ignore them if you always prebuild on CI.

Red flag: hand-editing a generated file inside ios/ or android/ instead of writing a plugin — the next prebuild wipes and regenerates those directories, silently discarding the edit.

"Config plugins are how I keep native customisation reproducible — the change lives in source control as code, not as a one-off manual edit that prebuild would erase."

EAS Build: Build Profiles, Credentials, and What Happens in the Cloud

What EAS Build actually does, how build profiles work, and the credential model.

level.mid

EAS Build runs your native build on Expo's infrastructure. You define build profiles in eas.json — named configurations (like development, preview, production) that control the platform, distribution type, environment variables, and whether credentials are needed.

A typical eas.json:

{
  "build": {
    "development": { "developmentClient": true, "distribution": "internal" },
    "preview":     { "distribution": "internal" },
    "production":  { "autoIncrement": true }
  }
}

Credentials: for iOS you need an Apple Developer team, a Distribution Certificate, and a Provisioning Profile. For Android, a keystore. EAS Credentials (eas credentials) generates and stores these on Expo's servers, encrypted. In CI you only need EXPO_TOKEN — EAS fetches credentials automatically. Never commit keystores or .p12 files to the repo.

The build flow: you run eas build --platform ios --profile production, EAS checks out your commit on a Mac builder (for iOS) or Linux (for Android), runs npx expo prebuild if needed, installs pods/Gradle deps, builds the native binary, signs it with your credentials, and uploads the artifact. The build logs are fully visible in the EAS dashboard. You get a download link or auto-submit to the stores depending on your profile config.

Red flag: committing a keystore or .p12 file "just for this build" — that's a permanent secret in git history. Store credentials on EAS (eas credentials) and give CI only EXPO_TOKEN.

"Build profiles let me keep dev, preview, and production as named, reproducible configs instead of one script with branching flags — and credentials never touch the repo."

OTA Updates with EAS Update: What Changes and What Doesn't

The JS-bundle update model, the update channels, and the limits you must understand.

level.mid

EAS Update (formerly Expo Updates) lets you push JS bundle changes to users without going through the App Store or Play Store review process. It updates the JavaScript layer only — no native code changes, no new binary, no review wait.

The model: you publish an update with eas update --branch production --message "fix layout". On next app launch (or after a configurable background check interval), the app downloads the new bundle and applies it on the subsequent launch. You can configure UpdatesConfig in app.json to control the check interval and whether updates are applied immediately or on next restart.

What OTA can update: all JavaScript and TypeScript code, assets bundled with the JS (images, fonts via require()), and JSON data files. What it cannot update: native code (Swift, Kotlin, Objective-C, Java), new native dependencies (anything that requires a CocoaPods or Gradle change), changes to Info.plist / AndroidManifest.xml, or new Expo SDK modules added after the binary was built.

Channels let you stage rollouts: eas update --branch staging for your test group, eas update --branch production for all users. You link a channel to a build profile in eas.json with "channel": "production". Critical rule: the update's SDK version must match the installed binary — pushing an update built against SDK 52 to a device running an SDK 51 binary will be ignored (or crash). EAS Update enforces this runtime version check.

Red flag: treating OTA as a substitute for App Store/Play review when the change touches native code — a new native dependency or an Info.plist/AndroidManifest.xml change requires a new binary and a real submission, no exceptions.

"OTA updates let me ship JS fixes without a store review cycle, but the runtime-version check is a safety rail, not red tape — it's what stops a JS bundle from loading into a binary it wasn't built for."

expo-router: File-Based Routing from Zero to Production

The routing model, layouts, and the patterns that make expo-router scale.

level.mid

expo-router brings the Next.js file-based routing mental model to React Native. Files in the app/ directory become routes: app/index.tsx is /, app/profile.tsx is /profile, app/settings/index.tsx is /settings.

Layouts: _layout.tsx files wrap every route in the same directory. They're where you add tab bars (<Tabs>), navigation stacks (<Stack>), drawers, or providers that all child screens share. The root app/_layout.tsx wraps the entire app — where you put auth guards, providers, and the splash screen logic.

Dynamic routes: app/post/[id].tsx matches /post/123. Inside, useLocalSearchParams<{"{"} id: string {"}"}>() gives you typed access to the segment. For catch-all routes: app/[...rest].tsx. For groups that share a layout without affecting the URL: app/(auth)/login.tsx — the parentheses make the segment invisible in the URL.

Navigation is router.push('/profile'), router.replace('/login'), or <Link href="/en/profile">. Modals: <Stack.Screen options={"{"}{ presentation: 'modal' }{"}"} /> inside the layout. expo-router integrates deeply with deep links and Universal Links — if you configure scheme in app.json and associatedDomains for Universal Links, expo-router handles route parsing automatically.

"File-based routing means the URL structure and the navigation structure are the same artifact — I don't maintain a separate route config that can drift from the file tree."

DATA & STATE

useState vs useReducer: Choosing the Right Tool

The decision rule — and why the wrong choice makes components hard to reason about.

level.junior

useState is a value setter — you hand it the next value. useReducer is a dispatch target for a pure transition function — you hand it an intent (an action), and the reducer decides the next state. Picking the wrong one doesn't break the app, but it makes multi-field state transitions much harder to reason about and test as the component grows.

Both hooks manage state in a function component — the difference is the shape of the update logic. useState is a setter: you call it with the next value (or a function of the old value). useReducer is a dispatch: you send a typed action and a pure function computes the next state.

Reach for useState when: the state is a single value or a small group of independent values, updates are simple assignments, and the component is local and not shared. const [count, setCount] = useState(0) — the setter is the whole API.

Reach for useReducer when: state transitions are complex (multiple fields that change together), the next state depends on the previous state in non-trivial ways, or you want the update logic testable in isolation. A reducer is a pure function — you can unit test every transition without rendering a component.

A practical signal: if you find yourself writing three related useState calls and the setters always fire together (setLoading(true); setData(null); setError(null)), consolidate them into one useReducer with a { loading, data, error } state shape. The reducer makes the transitions explicit and prevents the "partial update" bugs where one setter fires but another doesn't.

"I reach for useReducer the moment state fields start updating together — it turns implicit multi-setter choreography into one pure, testable transition function."

Zustand in React Native: Stores, Selectors, and Persistence

How Zustand's subscription model avoids re-renders — and how to persist to AsyncStorage.

level.mid

Zustand lives outside the React tree and uses pub-sub instead of context propagation — a component only re-renders when the specific slice it subscribed to changes, not on every store update. That subscription model is the entire performance argument for choosing it over Context for frequently-changing state.

Zustand stores are created outside React with create(). A store is a function that receives set and returns an object with state and actions. Components subscribe via a selector function — they re-render only when the selected slice changes, not on every store update.

const useTaskStore = create<TaskState>((set) => ({
  tasks: [] as Task[],
  addTask: (t) => set((s) => ({ tasks: [...s.tasks, t] })),
  removeTask: (id) => set((s) => ({ tasks: s.tasks.filter(t => t.id !== id) })),
}));

Selector discipline is the key performance lever: const tasks = useTaskStore(s => s.tasks) subscribes only to tasks. If you write const store = useTaskStore() (no selector), the component re-renders on any store change. For objects, combine with shallow from Zustand: useTaskStore(s => ({ tasks: s.tasks, add: s.addTask }), shallow).

Red flag: calling the store hook with no selector (useTaskStore()) "for convenience" — it throws away the entire re-render optimisation Zustand is chosen for and re-renders the component on every unrelated store change.

Persistence: wrap the store with persist middleware from zustand/middleware. In React Native, you need to provide a custom storage that wraps AsyncStorage:

import AsyncStorage from '@react-native-async-storage/async-storage';
const storage = { getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, removeItem: AsyncStorage.removeItem };
create(persist(myStore, { name: 'task-store', storage: createJSONStorage(() => storage) }));

For sensitive data, replace AsyncStorage with expo-secure-store in the same adapter. Note that expo-secure-store values are strings only — createJSONStorage handles serialisation for you.

"I choose selectors and persistence adapters, not whether the store re-renders too much — the subscription model already guarantees that; the discipline is picking the right storage backend for what's sensitive."

React Query in React Native: Fetching, Caching, and Background Refresh

The query key model, stale-while-revalidate, and the RN-specific AppState integration.

level.mid

React Query's job is server state — data that is owned by a server and can go stale — while Zustand/useState own UI state that lives only on the device. Conflating the two is why teams end up hand-rolling loading/error/retry/cache-invalidation logic that React Query already solves.

React Query (@tanstack/react-query) manages server state — data that lives on a server and needs to be fetched, cached, and kept fresh. It separates server state from UI state (which Zustand/useState handle) and handles the async lifecycle that you'd otherwise write by hand: loading, error, refetch, retry, pagination, and optimistic updates.

The mental model: every query has a query key (an array). React Query caches results by key. useQuery({ queryKey: ['task', id], queryFn: () => fetchTask(id) }) — on mount it checks the cache; if data exists and is fresh (within staleTime), it returns it immediately without a network call. If stale, it returns the cached data immediately (fast UI) and refetches in the background (stale-while-revalidate).

React Native integration tip: React Query's QueryClient has an onlineManager and a focus manager. On the web, focus manager uses the window focus event. In RN, you should wire it to AppState so queries refetch when the app foregrounds:

import { focusManager } from '@tanstack/react-query';
AppState.addEventListener('change', (state) => {
  focusManager.setFocused(state === 'active');
});

Mutations (useMutation) handle writes. Use onSuccess to invalidate the relevant query keys and trigger a refetch: queryClient.invalidateQueries({ queryKey: ['tasks'] }). For optimistic updates, set data immediately in onMutate and roll back in onError.

Red flag: reaching for an optimistic update to hide an initial data load. Optimistic updates work for mutations because you know the intended outcome and can roll it back on error; an initial load has no known outcome to guess — that's a staged/skeleton loading problem, not an optimistic-update problem.

"React Query owns server state end to end — cache, staleness, retries, invalidation — so my components only ever describe what data they need, not how to fetch or cache it."

AsyncStorage: The Right and Wrong Uses

What AsyncStorage actually is, what it's safe for, and what to use instead for sensitive data.

level.mid

AsyncStorage and SecureStore aren't interchangeable key-value stores with different names — AsyncStorage is a plaintext file cache for non-sensitive data; SecureStore is a hardware-backed credential vault. Picking AsyncStorage for a token isn't a style choice, it's a data-exposure bug.

@react-native-async-storage/async-storage is a simple key-value store backed by plain files on disk: on Android in /data/data/<package>/files/, on iOS in the app's Documents/ directory. Both locations are accessible to backups (iTunes/ADB) and — on rooted/jailbroken devices — to any process with elevated permissions. The data is plaintext.

Safe to store in AsyncStorage: UI preferences (dark mode, language), feature flags, cached non-sensitive API responses, onboarding completion flags, shopping cart state, non-financial local data. Anything where leakage causes embarrassment but not financial or personal harm.

Red flag: storing an auth token, password, PIN, or any account credential in AsyncStorage "temporarily" — the store is plaintext on disk and readable from backups on rooted/jailbroken devices. Never store in AsyncStorage: auth tokens (access or refresh), passwords, PINs, payment card data, social security numbers, health records, private keys, or any credential that grants access to an account. For these, use expo-secure-store, which writes to the iOS Keychain and Android Keystore — hardware-backed, backup-excluded, and app-sandboxed.

AsyncStorage is asynchronous and returns Promises. Common mistake: reading a value and assuming it's ready before the first render. Pattern: read in useEffect (or during app init in a loading screen) and hold a loading state until the read resolves. For Zustand stores, the persist middleware handles this with a hasHydrated signal you can await before rendering protected screens.

"I pick storage by sensitivity, not by habit — AsyncStorage for anything a leak only embarrasses, SecureStore for anything a leak compromises."

Offline-First Architecture: Queues, Sync, and Conflict Resolution

Designing a React Native app that works without a network connection and syncs reliably when it returns.

level.senior

An offline-first app treats the local database as the source of truth and the server as a sync target — the opposite of the typical "fetch, then render" model. Users can create, edit, and delete data without a connection; changes are queued and synced when the network returns.

The three components: a local store (SQLite via expo-sqlite, or a structured key-value store), a sync queue (pending operations persisted to disk so they survive app kills), and a sync engine (background process that drains the queue, handles retries, and resolves conflicts).

Conflict resolution is the hard part. Three strategies: last-write-wins (every record has a server timestamp; highest timestamp wins — simple but loses concurrent edits), operational transforms (merge character-level changes, used by collaborative editors like Notion — complex but lossless), and vector clocks / CRDTs (data structures that merge automatically without central coordination — ideal for append-only or set-like data). For most CRUD apps, last-write-wins per-field with a conflict notification ("this item was edited on another device") is sufficient and practical.

Defending last-write-wins to a skeptic who wants full CRDT merging: yes, it can silently discard a concurrent edit — that's a real cost. But the alternative, building CRDT or operational-transform merging for a plain CRUD app, is weeks of complexity to solve a conflict rate that's often near zero in practice, and it still needs a UI for the rare true conflict. Last-write-wins with a visible "edited elsewhere" notice is the investment that ships; add CRDTs only for the specific entities where concurrent edits are frequent and silent loss is unacceptable — and budget for the ongoing complexity of merge logic and schema evolution that comes with them.

React Native tools: expo-sqlite for structured local data, @react-native-community/netinfo for reachability detection, React Query's networkMode: 'always' to queue mutations offline. For production offline-first apps, consider WatermelonDB (a lazy-loading, observable SQLite layer designed for RN) or PowerSync/Triplit for built-in cloud sync.

"I default to last-write-wins with a visible conflict notice, and only reach for CRDTs on the specific data types where concurrent edits are frequent enough that silent loss is unacceptable — full conflict-free merging is a cost I don't pay everywhere by default."

PERFORMANCE

useMemo and useCallback: When They Help and When They Hurt

The mental model for memoisation — and why overusing it makes performance worse.

level.mid

useMemo caches a computed value; useCallback caches a function reference. Both only help when: (1) the computation is expensive, OR (2) the result is passed as a prop to a React.memo-wrapped child or a hook dep that compares by reference.

When they do NOT help (and add cost): wrapping a cheap computation (useMemo(() => a + b, [a, b]) — the overhead of running the memo logic exceeds the cost of the addition), or stabilising a function that isn't passed anywhere deps-sensitive. Every useMemo/useCallback call adds to React's bookkeeping — the hook has to store the previous deps, compare them on every render, and decide whether to return the cached value or recompute.

The right question before memoising: do I have a measured performance problem caused by this specific value/function changing? Profile first with React DevTools (the Profiler tab) or with why-did-you-render. If a child re-renders unnecessarily because a callback prop changes identity on every render, that's when useCallback pays for itself.

React.memo wraps a component and skips re-render if all props are shallowly equal. It's most valuable for list items (rendered many times, expensive subtrees) and heavy pure components that receive stable props. Combining React.memo on the child with useCallback/useMemo for the props it receives is the correct pattern — neither alone is complete.

Red flag: wrapping every value and function in useMemo/useCallback "for performance" without profiling first — each call adds bookkeeping cost, so blanket memoisation can make a component slower, not faster.

"I memoise only after profiling shows a specific value or callback is causing a specific, unnecessary re-render — not as a default habit."

FlatList vs FlashList: Windowing, Recycling, and When It Matters

How virtualisation works in React Native and when FlashList is worth the dependency.

level.mid

Virtualisation means only the rows visible on screen (plus a small buffer) are rendered as native views. Rows that scroll off-screen are unmounted (or their views recycled). Without it, a 1000-item list creates 1000 native views up front — slow initial render and high memory usage.

FlatList (built into React Native) virtualises by default. Key performance props: keyExtractor (stable unique key per item, avoid array index), getItemLayout (if all items are the same height, providing this avoids measuring every row and dramatically speeds up scrollToOffset/scrollToIndex), windowSize (default 21 — number of viewport heights to render above/below the visible area; reduce to 5–7 for memory-heavy lists), initialNumToRender (items to render before the first paint; set to the number visible on screen).

Red flag: using the array index as keyExtractor — when items are inserted, removed, or reordered, React matches the wrong item to the wrong key, causing stale row content and broken animations. Use a stable ID from the data.

FlashList (@shopify/flash-list) recycles native views (like RecyclerView on Android / UICollectionView on iOS). Instead of unmounting off-screen items, it reuses the existing views and updates their content — no native view creation/destruction cost. This is faster for fast scrolling through long lists and measurably reduces frame drops on large feeds. The trade-off: slightly more complex API, and items must have a consistent size type (overrideItemType) for best recycling.

Decision rule: for fewer than ~100 items, FlatList is fine. For feeds that can grow to hundreds or thousands of items, FlashList is the upgrade worth taking. Always test on a real low-end device (not the simulator) — the difference only shows up in realistic conditions.

"FlatList unmounts off-screen rows; FlashList recycles the native views instead of destroying them — that's the whole reason it wins on fast-scrolling, high-volume feeds and not on a 20-item list."

Image Optimisation in React Native

Format choices, caching, and why Expo Image replaces the built-in Image component.

level.mid

Images are the number-one source of memory pressure in most React Native apps. Unoptimised images cause janky scrolling, OOM crashes on low-end Android, and slow time-to-first-meaningful-paint.

Format: prefer WebP over JPEG/PNG for network images — same quality at 25–35% smaller file size. For icons and logos, SVG (via react-native-svg) scales without quality loss and has zero size penalty. PNG only for images that require transparency and can't use WebP.

Expo Image (expo-image) is the replacement for the built-in <Image> component. It adds: disk and memory caching (so the same image URL doesn't re-download), blurhash placeholder (a tiny hash that renders a blurred preview while loading), priority loading (priority="high" for above-the-fold images), and the modern contentFit/contentPosition props replacing resizeMode. It uses Glide (Android) and SDWebImage (iOS) under the hood — battle-tested native image loaders.

Sizing: always specify explicit width and height or use style={"{"}{"{"}flex: 1{"}"}{"}"} in a bounded container. Images without dimensions don't render until the native side measures them — a visible layout shift. For remote images of unknown dimensions, fetch the dimensions first or use an aspect-ratio container (aspectRatio: 16/9 + width: '100%').

Red flag: rendering a remote image with no explicit size "because it'll size itself" — it won't paint until native measures it, producing a visible layout shift as content jumps once the image loads.

"I never ship an unsized remote image — either I know the dimensions up front or I reserve the space with an aspect-ratio container, so nothing shifts once the image loads."

Profiling React Native: DevTools, Systrace, and What to Look For

The tools for finding dropped frames, slow JS, and native-side bottlenecks — and how to read the output.

level.senior

Three profiling tools, from easiest to deepest:

React DevTools Profiler — shows which components re-rendered during a recorded interaction and how long they took. Look for: components with a high "Render duration" bar that shouldn't be re-rendering (the grey "did not render" state is your target for static components), large trees that re-render when only one leaf changed (a missing React.memo), and the "Why did this render?" tool that names the prop or state that triggered the re-render.

Flipper (JS/Metro profiler) — Flipper's Performance tab captures CPU profiles of the JS thread. Look for: long tasks that block the JS thread (the thread can only do one thing at a time), synchronous storage reads blocking renders, and expensive serialisation (JSON.parse of large payloads on the main thread). Move long computations to a background thread with expo-task-manager or to a worklet with Reanimated.

Systrace / Android GPU profiler / Xcode Instruments — the native layer. For iOS: Instruments' Core Animation template shows frame timing. For Android: adb shell atrace or the GPU profiler in Android Studio. Use these when you've ruled out JS-thread issues and suspect layout inflation, expensive native measure passes, or shader compilation stutter (the "jank on first scroll" problem on Android — use initialNumToRender and warm-up the GPU before the list is shown).

The universal diagnosis flow, in order — don't skip a step: 1. Isolate the thread. Is the JS thread pegged, or is it a native draw problem? 2. Prove it with the matching tool. React DevTools Profiler for re-renders, Flipper's CPU profiler for JS-thread work, Systrace/Instruments for native frame timing. 3. Fix the outermost layer first — a re-render fix is wasted effort if the actual bottleneck is native layout inflation.

React Native Animations: Animated API vs Reanimated vs Skia

Which animation library is right for which use case — and why running on the UI thread matters.

level.senior

React Native's built-in Animated API drives animations by passing values across the bridge (or JSI in New Architecture). Simple animations (fade, translate) work fine, but any animation that updates every frame needs a frame-by-frame value calculated on the JS thread — and any JS work that takes too long drops the frame. This is why complex gesture-driven animations feel laggy with the basic Animated API.

React Native Reanimated (react-native-reanimated) solves this by running animation logic as worklets — small functions compiled to run directly on the UI thread. Gesture callbacks, spring physics, and interpolations all execute without touching the JS thread. The result: silky 60/120fps animations even if the JS thread is busy. The API: useSharedValue (a value that lives on the UI thread), useAnimatedStyle (derives styles from shared values, runs on UI thread), and withSpring/withTiming animation functions.

React Native Skia (@shopify/react-native-skia) is for custom rendering — drawing paths, gradients, blur effects, and animations that the native view system can't express. It runs a Skia canvas on a separate thread, independent of both JS and the UI thread. Reach for it when you need chart animations, particle effects, custom UI that looks identical on both platforms, or blur/filter effects without the GPU limitations of Blur components.

Decision: simple show/hide → Animated. Gestures, parallax, shared element transitions → Reanimated. Custom drawn UI or complex visual effects → Skia. All three can coexist in the same app.

Red flag: blaming "React Native performance" for janky gesture-driven animation built with the basic Animated API — the fix is moving the animation logic onto the UI thread with Reanimated, not a native rewrite.

"I pick by where the animation logic needs to run — Animated for simple transitions, Reanimated when gesture response has to stay on the UI thread, Skia when I'm drawing pixels the native view system can't express."