React Native runs your React code and renders real platform views. There is no browser to smooth over the two operating systems underneath, and most of what trips up an experienced React developer is not unfamiliar API — it is a familiar name whose defaults changed, or a cost the browser used to absorb. This guide is about those deltas: what you are really rendering, why styling only resembles CSS, where the JavaScript-to-native boundary sits, and which platform differences you cannot postpone.
Each chapter opens with the short version. Tap one to read the detail.
There is no DOM — what you are actually rendering
~2 min
Your elements become real platform views: a <View> is an Android ViewGroup or an iOS UIView, not a div. Two consequences bite at once — every string must sit inside a <Text>, and the built-in component set is small.
React Native runs the React you already know, but the renderer targets host views rather than nodes in a Document Object Model (DOM). At runtime View becomes a ViewGroup or a UIView, Text a TextView or UITextView, TextInput an EditText or UITextField.
The misconception to kill on day one is that this is a web view with extra steps. There is no document tree to reach into: no selectors, no document, nothing to query after the fact.
The rule with real teeth is text: a bare string must never sit directly under a <View>. The legacy renderer throws Text strings must be rendered within a <Text> component; Fabric reports the same message through console.error in development.
<View>Hello</View> // wrong — a text node cannot live here
<View><Text>Hello</Text></View> // correct
The component set is deliberately small — View, Text, Image, TextInput, ScrollView, Pressable, FlatList, Modal — so video, maps, vector graphics and camera are all community packages. Several are scoped to one platform by name, like ToastAndroid and ActionSheetIOS.
Rule: when you reach for an HTML element, translate the intent — ask which native view you want, and the answer is almost always a View wrapping a Text.
Styling and layout: it resembles CSS and it is not CSS
~3 min
Styles are plain JavaScript objects with camel-cased names, and the resemblance ends there: no selectors, no cascade, no inheritance outside a text subtree. Layout is flexbox computed by Yoga, and four of its defaults differ from the web — flexShrink: 0 being the one that ships bugs.
Style names and values usually match the web, written in camel case: backgroundColor rather than background-color. There are no selectors and no cascade, so sharing a look means sharing an object; an array of styles merges, and the last entry wins.
The first trap is inheritance. You cannot set a font on a container and have descendants pick it up — React Native limits inheritance to text subtrees, so a <Text> inside another <Text> inherits and nothing else does. The documented answer is your own text component, used everywhere.
Sizes are unitless and represent density-independent pixels. Percentages and flex both need a parent that already has a size: a flex child inside a parent with no height is invisible, which is the most common "my screen is blank" cause.
Layout is flexbox, and the documentation is blunt that it works as on the web "with a few exceptions" — all of them defaults. flexDirection defaults to column instead of row, alignContent to flex-start instead of stretch, flexShrink to 0 instead of 1, and flex accepts only a single number.
flexShrink: 0 is the one that ships. On the web a row holding a long label and a fixed badge squeezes the label; here nothing shrinks unless told to, so the badge is pushed past the screen edge — no error, no warning, and it looks fine in a wide desktop window.
// At 375 points wide the badge is pushed off-screen without `flexShrink`
<View style={{flexDirection: 'row', flexWrap: 'wrap'}}>
<Text style={{flexShrink: 1}} numberOfLines={1}>{skillName}</Text>
<DifficultyBadge />
</View>
The remaining divergences are absences: position: 'absolute' positions against the parent, not the page, and there is no fixed positioning, no float and no grid. Layout is also not computed by your code — Yoga measures the tree during the commit phase, which is why a measured size arrives through an onLayout callback rather than during a render.
Rule: assume nothing inherits and nothing shrinks. If two things should look alike they share an object, and every row with variable-width content needs an explicit flexShrink: 1 — checked at 375 points wide, because a laptop window hides exactly that bug.
Touch is a negotiation, and text entry is a controlled input
~2 min
There is no click. Touches run through a responder system in which exactly one view holds the touch at a time, and a parent can claim it from a child. TextInput is controlled much as a web input is, but its props are full of one-platform values.
The responder system lets components negotiate a touch without knowing anything about their parents or children, and its central fact is exclusivity: only one view is the responder at a time. A view asks with onStartShouldSetResponder, is granted the touch through onResponderGrant, then receives onResponderMove and onResponderRelease.
Two parts surprise people. Before the request bubbles up from the deepest component there is a capture pass, so a parent whose onStartShouldSetResponderCapture returns true takes the touch before the child sees it. And the operating system can seize the responder without asking — pulling down Control Center on iOS fires onResponderTerminate. This is why nesting a scrollable inside a pressable can leave a drag mysteriously dead: your handler is fine, the responder is elsewhere.
Because there are no pseudo-classes, a pressed appearance is state rather than a selector.
TextInput is controlled: pass value and the native field is forced to match it. The platform edges are where time goes — secureTextEntry does not work with multiline, autoCapitalize defaults to sentences so a username field capitalizes itself, and a multiline field aligns text top on iOS but centers it on Android unless you set textAlignVertical: 'top'.
Rule: when a gesture goes dead, stop debugging the handler and look at the nesting — exactly one view owns that touch.
Scrolling: the decision that makes an app feel native
~2 min
ScrollView renders every child immediately; FlatList mounts rows as they approach and drops ones that scroll away. Choosing wrong is the commonest cause of a slow screen, and the blank flashes people blame on the framework are a tuning problem with named knobs.
A ScrollView "renders all its react child components at once", creating every component and native view up front even for content nobody scrolls to. FlatList renders items lazily as they are about to appear and removes them once they scroll well off screen.
A ScrollView also needs a bounded height, because it puts unbounded-height children inside a bounded container — forgetting to pass flex: 1 down the stack is why one refuses to scroll at all.
Blank cells during a fast flick mean the list could not render in time. The knobs and their trade-offs: initialNumToRender (default 10) should cover the first screen exactly; windowSize (default 21, in viewport heights) trades memory for fewer blanks; maxToRenderPerBatch (default 10) fills faster but runs JavaScript longer, delaying taps; removeClippedSubviews (default true on Android) detaches off-screen views but has known bugs with transforms and absolute positioning.
Before touching any of those, make the row cheap. The largest single win is getItemLayout when rows share a height: it removes asynchronous measurement entirely.
const renderItem: ListRenderItem<Item> = useCallback(({item}) => <Row item={item} />, []);
<FlatList
data={items}
renderItem={renderItem} // typed and stable, not an inline arrow
keyExtractor={item => item.id}
getItemLayout={(_, i) => ({length: ROW_H, offset: ROW_H * i, index: i})}
/>;
Rule: ScrollView for a screen's worth of content, FlatList for anything unbounded — and make the row light and stable before tuning a single prop.
Animation belongs on the other thread
~2 min
Without useNativeDriver: true an animation is driven frame by frame from JavaScript, so it stutters whenever your code is busy. With it, the animation is handed to native before it starts — but only non-layout properties qualify, and asking for a layout property fails quietly.
The native driver sends everything about an animation to native up front, so it runs on the main thread without crossing back every frame: once started, the JavaScript thread can be blocked without affecting the animation. That is a spinner that keeps spinning while you parse a response, instead of freezing at the worst moment.
The limit: only non-layout properties can be animated. Transforms and opacity work; flexbox and position properties do not. Checked against react-native 0.85.3, the allowlist enforcing this accepts opacity, transform, colors, border radii, elevation and zIndex — and an unsupported property is reported through console.error, not an exception. So animating height on the native driver produces a line in a log you may not be watching and a view that does not move.
const [fade] = useState(() => new Animated.Value(0));
Animated.timing(fade, {
toValue: 1,
duration: 200,
useNativeDriver: true, // opacity qualifies; height would not
}).start();
<Animated.View style={{opacity: fade}} />;
Two further constraints: a value is compatible with one driver only, so every animation on it must make the same choice; and only Animated.View, Text, Image and the list components are animatable out of the box — anything else goes through Animated.createAnimatedComponent().
Rule: choose the property before the animation — if it is not a transform or opacity, redesign the motion rather than dropping the native driver.
Two platforms, and the users neither of them assumes
~2 min
One codebase is not one interface. Platform.select and .ios.tsx / .android.tsx handle the forks you plan for; the costly differences are the silent ones — right-to-left layout that flips only after a restart, and accessibility props that exist on one platform only.
Platform.OS is ios or android. Platform.select takes an object keyed by ios, android, native and default, preferring the most specific match available. For anything larger than a style fork, a file named Button.ios.tsx or Button.android.tsx resolves automatically when you import ./Button without the suffix.
Platform.Version is the trap inside the escape hatch: on Android it is the platform level as a number, on iOS the operating-system version as a string. Comparing them the same way is a bug that type-checks. The rest accumulate quietly — negative margins are unsupported on Android, and removeClippedSubviews defaults to true only there.
I18nManager owns layout direction, and its allowRTL and forceRTL settings persist but take effect on the next application start, not immediately — so an in-app language switch does not flip the layout until relaunch.
Accessibility is where the split hurts most, because there is no heading or button element to carry meaning. Every semantic is a prop: accessibilityRole for purpose, accessibilityState for live state, accessibilityLabel for what gets spoken. The structural surprise is that marking a container accessible puts focus on the container rather than its children — right for a card that is one idea, wrong for a row of three controls, and a decision about the component tree rather than a label added later.
Even here the platforms diverge: accessibilityLiveRegion is Android-only, accessibilityViewIsModal iOS-only, and an accessibilityHint can be switched off by an iOS user while Android's TalkBack always reads it — so never put required information in one.
Rule: run every screen on both platforms with the screen reader on, the day you build it. Every difference in this chapter is invisible on the platform you happen to develop on.
The boundary: what crossing between JavaScript and native costs
~2 min
Your code runs in a JavaScript engine; the views live in native. The old architecture passed serialized messages across an asynchronous bridge; the current one lets JavaScript hold a direct reference to a C++ object, which is what made synchronous layout access possible.
The old model was an asynchronous bridge where each call was a serialized message. The New Architecture, enabled by default since React Native 0.76, replaces it with an interface letting JavaScript hold a reference to a C++ object and invoke methods directly, without serialization cost.
Rendering runs as three phases. In render, React reduces your elements to host components and the renderer builds a shadow tree in C++. In commit, Yoga calculates each node's position and size and the new tree is promoted. In mount, the difference between trees becomes atomic create, update and remove operations applied on the UI thread — the only thread allowed to touch host views. The shadow tree is immutable: updating a node means building a new tree.
The engine is Hermes, the default, which compiles your JavaScript to bytecode at build time — improving start-up, memory use and app size against the alternative engine.
For a platform capability React Native does not ship, you cross deliberately: you write a typed specification and Codegen generates the platform glue from it — the spec is the contract.
// Codegen generates the Android and iOS glue from exactly this
export interface Spec extends TurboModule {
getItem(key: string): string | null;
setItem(value: string, key: string): void;
}
The misconception to retire is "the bridge is gone, so calls are free." Direct invocation removed serialization; it did not remove the thread boundary.
Rule: design so the boundary is crossed rarely and in batches — per-frame chatter is the cost that survived the rewrite.
Screens stay mounted, and other long-lived-process surprises
~2 min
Navigation is a library choice, not a framework feature, and a native stack navigator wraps the platform's own controller. The consequence people trip on: navigating away does not unmount the screen, so an effect that runs on mount runs once and never again.
React Native ships no navigator. The common answer is React Navigation with react-native-screens and react-native-safe-area-context, where createNativeStackNavigator uses the platform's own machinery (UINavigationController on iOS, a Fragment on Android), so transitions feel native.
The mistake to expect: useEffect(fn, []) re-running when the user comes back. React Navigation does not unmount screens when you navigate away: push a second screen and the first stays mounted, and coming back shows the existing instance rather than remounting it. So useEffect(fn, []) means "once per stack entry", not "every time the user looks at it"; anything that should refresh on return belongs in the focus and blur events or useFocusEffect.
useFocusEffect(
useCallback(() => {
refresh(); // runs every time the screen becomes visible
}, [refresh]),
);
Hooks behave exactly as in React — calling a state setter re-renders the component — but the conditions differ. A phone app is a long-lived process, so a stale closure or a subscription you forgot to clean up lives for hours across dozens of screens, not for the seconds until the next page load.
That longevity argues for keeping state small and local: a fast-changing value in a context re-renders every consumer, which on a list screen means the list. And because a style is a value here rather than a stylesheet rule, a component accepting a caller's styles should type that prop — StyleProp<ViewStyle> — not leave it any.
Rule: ask whether something must happen once or every time the user sees the screen — mount only ever answers the first.
Talking to the outside world, and what the client cannot protect
~2 min
fetch works and the browser's rules around it do not: there is no cross-origin check in a native app, but both platforms block unencrypted traffic by default. Storage is unencrypted unless you choose an encrypted store, and anything inside the bundle is readable.
fetch and XMLHttpRequest are both built in, so libraries depending on them work unchanged. The security model differs: there is no concept of the browser's cross-origin check — CORS — in a native app, so a request a browser would have refused simply goes out.
What does block you is cleartext. iOS 9.0 and later enforce App Transport Security, requiring encrypted connections, and Android blocks cleartext traffic by default from platform level 28 onward — so an unencrypted development server that works in a simulator fails on a device until you add an explicit exception.
Async Storage is asynchronous and unencrypted: correct for preferences and cached state, wrong for tokens, which belong in iOS Keychain Services or, on Android, in encrypted shared preferences backed by the Keystore.
The hard boundary, stated plainly in the documentation: never store sensitive keys in your app code, because anything in it can be read in plain text by anyone inspecting the bundle. Minification is not protection. The documented fix is an orchestration layer — a server endpoint that holds the secret and forwards the request.
// Wrong: shipped to every device, readable in the bundle
const KEY = 'sk_live_…';
// Right: your server holds the secret; the app calls your endpoint
await fetch(`${BASE}/summarize`, {method: 'POST', body});
Deep links deserve the same suspicion: another app can register your scheme, so they must never carry sensitive data. Permissions are staged and revocable: location splits foreground from background, background cannot be requested first, and higher accuracy costs battery.
Rule: assume every byte you ship is public and every permission is a question the user may answer no to.
Measuring, debugging and shipping
~3 min
You get about 16.67 ms per frame, and a blocked JavaScript thread looks nothing like a blocked UI thread — telling them apart is most of the diagnosis. Every number from a development build is wrong, and the tooling you rely on is absent from the build users get.
Devices display at least 60 frames a second, leaving at most 16.67 ms per frame; miss it and you drop a frame and the interface reads as unresponsive.
When the JavaScript thread is blocked, animations it drives freeze, taps respond late, and slow navigator transitions are the commonest symptom — while scrolling stays perfectly smooth, because a scroll view lives on the main thread and needs nothing from your code. So a smooth scroll with a dead tap points at JavaScript; juddery scrolling itself points at the main thread. On iOS, the Perf Monitor in the Dev Menu shows both frame rates side by side; on Android it shows only the UI rate.
The mistake everyone makes once is profiling a debug build. JavaScript performance suffers greatly in development mode, unavoidably, because far more work happens at runtime to produce good warnings and errors. Measure release builds only. Logging is part of that cost: in a bundled app console calls are a real bottleneck on the JavaScript thread.
// Babel config: keep logging out of production bundles
env: {production: {plugins: ['transform-remove-console']}}
In development the app fetches its bundle from Metro on your machine, with the Dev Menu, LogBox and React Native DevTools attached; all three are disabled in release builds, where the bundle ships inside the binary. So a production defect is investigated through crash reports and instrumentation you added on purpose. Signing is not optional either: Android release builds must be signed, and iOS will not run on a device until code signing is configured.
Expo is a set of packages rather than a fork: its modules work in any React Native app with the expo package installed. The distinction that matters is Expo Go versus a development build: Expo Go is a fixed, pre-built app, so adding a library with native code means building your own.
npx expo prebuild --clean # after adding a library with native code
npx expo run:ios # then rebuild — no remote update can deliver this
Remote updates cover JavaScript, styling, copy, translations and layout. They cannot change native code, native dependencies, permissions, or the Expo release the app was built against — which is what runtime versions enforce, so old binaries are never handed a bundle they cannot run.
Rule: name the thread before you optimize, and sort every change into "JavaScript" or "native" before you promise a date — only one of them can ship today.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.