Everything the adaptive question bank can teach and test in React Native, from foundations through advanced practice. Work through it in order, or start practicing and let the questions find your level.
A Switch here is the platform's own control, a UISwitch on iOS and an AndroidX SwitchCompat on Android, so it looks, animates and reads to screen readers the way each system's switches do. The flip side: a web library that touches the DOM cannot run, and real HTML needs an explicit WebView component.
The mapping has an exception: a View that only arranges its children, with no background, border, opacity, transform or testID, is flattened away and never becomes a native view. Pass collapsable={false} when a native module or library must find that exact view in the hierarchy.
window exists only as another name for the global object, so a library that tests typeof window to detect a browser can mistake React Native for one and then crash on document, which does not exist. Read screen size from useWindowDimensions(), and check navigator.product === 'ReactNative' when code must tell the two apart.
Under the New Architecture renderer, the only one since 0.82, a development build logs Text strings must be rendered within a <Text> component. and draws nothing, while a release build drops the string silently. The usual culprit is {count && <Badge />} with count at 0, which hands React a bare 0; an empty string is skipped.
Navigation, storage, WebView, pickers and sliders live outside the react-native package, so old code importing AsyncStorage from it fails with a dev error naming the replacement. Before adding a library, check whether it ships native code: if so, you need a fresh native build, while a JavaScript-only one needs just a Metro reload.
While a long synchronous loop runs, taps go unanswered until it finishes and JavaScript-driven animations freeze, yet a ScrollView keeps scrolling because its scrolling happens on the UI thread. On iOS the Perf Monitor shows the split: a JS frame rate that sags beside a steady UI frame rate points at your JavaScript, not the platform.
The core Button shows the split: its color prop tints the label on iOS but fills the background on Android, which also uppercases the title. Plan for divergence by branching on Platform.OS, or by shipping .ios.tsx and .android.tsx versions of a file wherever the two platforms' conventions differ.
Each core component forwards only the props its native view declares, so web habits such as className and href are dropped from a View at runtime without an error. The same filter explains platform-only props: clearButtonMode reaches the iOS text field and is discarded on Android, since only the iOS view declares it.
An EXPO_PUBLIC_ variable is inlined into the bundle as plain text, so any API key placed there belongs to everyone who installs the app. Route such calls through a server function that holds the key, keep tokens in secure storage such as expo-secure-store, not unencrypted AsyncStorage, and re-check prices and permissions server-side.
B. Core components: View, Text, Image and friends
A View neither scrolls nor answers taps by itself: content past the screen edge stays out of reach until you wrap it in a ScrollView, and a tap target needs Pressable. What it does carry is onLayout, which reports its measured size, and the low-level responder props that Pressable is built on.
A View cannot pass color or fontSize down to the Text inside it, so a consistent typeface comes from your own wrapper, such as an AppText component, rather than a style on a screen's root. Inside a Text, nested children inherit its styles and flow inline like spans, which is how one sentence mixes bold and plain words.
ellipsizeMode decides where the ellipsis lands, tail by default, but on Android only tail truncates correctly once numberOfLines exceeds 1, and clip works on iOS alone. The cap counts wrapped lines, so the same string truncates sooner on a narrow phone, and numberOfLines={0} removes the limit.
No error appears, because nothing failed: the image is laid out at 0 by 0, and layout never grows to fit the picture the way a browser's img does. Give it a width and height, or one side plus aspectRatio; a require() image is exempt because Metro records its dimensions when it bundles the file.
Metro reads every require() argument while it builds the bundle, so a path assembled from a prop or state value fails the build with an Invalid call at line … error before anything renders. Keep an object of literal require calls, one per image, and pick from it at runtime, as in icons[name].
require() evaluates to a number indexing an asset registry, not to a path, so logging it prints an integer and no path can be built from it. Image also takes a web-style src string, converted into the same { uri } source and preferred over source, while custom headers, such as an auth token, fit only the object form.
Button takes no style prop, so any button that must match your design is a Pressable you style yourself, and Switch is the only toggle in core. Sliders, pickers, date pickers and checkboxes come from community packages such as @react-native-community/slider, or from your own Pressable given a matching accessibility role.
Only stretch can distort, because it scales width and height separately; the others keep proportions and differ in what they give up. cover, the default, crops the overflow, contain leaves empty bands, center behaves like contain but never enlarges a small image, and repeat tiles the picture at its own size.
Under the hood it is a plain View holding an Image spread across it with StyleSheet.absoluteFill, then your children, so style shapes the outer box while imageStyle reaches the picture. Give it a size, since an absolutely placed image adds nothing to its container's dimensions, and round the picture's corners in imageStyle.
Because it sits outside your layout, no parent's overflow or zIndex can clip or cover it, yet it stays in your React tree, so context and props still reach its children. On Android the back button calls onRequestClose rather than closing anything, so that handler must set visible, which defaults to true, to false.
The merge is per prop, so a newer instance that sets only hidden leaves an older one's barStyle in force. Tab and drawer screens stay mounted after their first visit, so the last tab mounted keeps control after you switch away; render each screen's StatusBar only while that screen is focused.
A recycled list row reuses its view for a new item, so give expo-image a recyclingKey tied to the item, or the old picture lingers until the next one loads. placeholder={{ blurhash }} paints a blurred preview from a short string, a numeric transition cross-fades over that many milliseconds, and cachePolicy defaults to 'disk'.
react-native-svg cannot import a .svg file on its own: you inline the markup with SvgXml, or add a Metro transformer such as react-native-svg-transformer that turns each file into a component. Draw shapes with fill="currentColor" and the color prop recolors the whole icon, so one asset serves light and dark themes.
Sound now comes from expo-audio, since expo-av left Expo Go and stopped receiving fixes in SDK 55. A looping background clip keeps decoding frames, and drawing battery, for as long as it plays, so keep it short, small and muted, pause it when its screen loses focus, and use a still image when the motion adds nothing.
useAudioPlayer and useVideoPlayer release their player when the component unmounts, but one made with createAudioPlayer or createVideoPlayer lives until you call release(). Unmounting also comes later than you might expect: a stack screen you navigate away from stays mounted, so pause playback on blur with a focus effect.
In SDK 56's expo-audio and expo-video, play() and pause() return at once and you assign loop, volume, muted and playbackRate as plain properties, while seeking in expo-audio awaits seekTo(). A finished expo-audio track stays parked at its end, so replay it with seekTo(0) before play().
In expo-audio, useAudioPlayerStatus(player) re-renders with each status update, every 500 ms by default, and didJustFinish is true only in the update sent at the end, so act on it when it arrives. During a stall playing reads false while isBuffering is true, so drive a spinner from isBuffering, not the play button.
With expo-audio, players pause in the background unless setAudioModeAsync sets shouldPlayInBackground, and iOS also needs the audio background mode that the config plugin adds. Set playsInSilentMode and interruptionMode explicitly, since they decide whether silent mode mutes you and whether other apps keep playing.
That split is finished: SDK 55 dropped expo-av from Expo Go and stopped patching it, and expo-video separates the player object from the VideoView that shows it, where an HTML video tag is both. A streamed HLS .m3u8 source starts before the file has downloaded and can adapt its quality, while a bundled clip plays offline but enlarges every install.
window.ReactNativeWebView.postMessage exists in the page only once you pass onMessage, and it carries a single string, so objects travel as JSON. Any page loaded in that view can call it, including one reached by following a link, so check event.nativeEvent.url before acting and expose only narrow, harmless actions.
Good fits are content you do not own or that changes without an app release: a help center, a hosted checkout, a third-party embed. Each one embeds a full browser engine, its pages ignore your fonts and theme unless they adopt them, and Apple's review guidelines turn away an app that is little more than a repackaged website.
An html string has no address of its own, so relative image and link paths inside it resolve only against a baseUrl you pass beside it. The package carries native code, so a bare React Native app needs a new native build after installing it, while Expo Go already includes it.
Android's back button leaves your screen, not the page, until a BackHandler listener calls goBack() while canGoBack is true; iOS swipe-back needs allowsBackForwardNavigationGestures. iOS keeps WebView cookies apart from your app's fetch unless sharedCookiesEnabled is set, while Android's networking already shares the WebView cookie manager.
C. TextInput and text entry
Each keystroke still reaches the native field, but the change event triggers a render that writes value back, so the character flashes and vanishes. Seed an editable field with defaultValue; lock one with readOnly, which refuses input without flicker. Only a string is enforced: value={undefined} leaves the field uncontrolled.
On iOS numeric opens the same decimal pad as decimal-pad, with no minus key, while Android's numeric accepts a sign. iOS number pads also lack Return, so React Native adds a toolbar button only on request, for instance with returnKeyType='done'. Neither platform filters input: a paste or hardware keyboard can still enter letters.
On iOS it also turns off copying. It does not work with multiline, since iOS then uses a UITextView and Apple obscures typing only in a UITextField, nor on Android beside keyboardType'email-address' or 'phone-pad'. AutoFill stays on: autoComplete='current-password' or 'new-password' tells both systems what to offer.
With no fixed height the field grows a line at a time; cap it with maxHeight and it scrolls inside instead. iOS adds 5 points of top padding when you set no top padding, while Android centers text vertically, so a tall empty box shows its placeholder halfway down until you set textAlignVertical: 'top'.
By default a single-line field submits, then blurs, so Return fires onSubmitEditing and then onEndEditing; in a multiline field Return adds a newline and fires neither. onEndEditing follows any loss of focus, such as moving to another field or Keyboard.dismiss(), and unlike onBlur its event reliably carries the final text.
Out of the box a field capitalizes each sentence's first letter and lets autocorrect rewrite words, so jdoe arrives as Jdoe or a dictionary word. Set autoCapitalize='none' and autoCorrect={false} on usernames, emails, codes and URLs; on iOS that also clears spell-check underlines, as spellCheck follows autoCorrect unless set.
autoFocus acts once, when the field first attaches to the window, so toggling it later does nothing; call ref.current?.focus() from an effect or handler. To chain fields, give each returnKeyType='next' and submitBehavior='submit', then focus the next ref in onSubmitEditing, so focus moves without the keyboard closing first.
Android notices the keyboard by watching window insets and layout, and its events carry duration: 0 with no start frame, so nothing can be timed to the slide. iOS also sends keyboardWillChangeFrame and keyboardDidChangeFrame. On both, Keyboard.isVisible() and Keyboard.metrics() update from the Did events, so they lag the motion.
Return acts on one field only, so wiring the form is your job: onSubmitEditing focuses the next field's ref, and on the last field it calls your submit handler. Keep values and errors in state and check them on blur and on submit, not at every keystroke. Repeat each check on the server, since a modified client can skip them.
clearButtonMode (single-line fields only) and textContentType are iOS-only. autoComplete is portable: Android reads it as an autofill hint, while iOS maps it to textContentType unless you set that yourself. maxLength and editable are enforced natively on both, so neither flickers like trimming text in onChangeText.
Native shows each key at once and reports it with an event count; the next render sends value back with that count, and native ignores replies older than its latest edit. Keys vanish when your value lags its event, because the stale text then carries a current count. Debounce the work, never the value.
D. Touch, Pressable and the responder system
A View has no press handler of its own; touches arrive through the responder system, which Pressable turns into onPressIn, onPress and onLongPress. Text is the exception and takes onPress directly, which is how a link inside a paragraph works. Once you also set onLongPress, a long hold fires it instead of onPress.
Pressable keeps pressed in state only when style or children is a function, so a static style costs no extra render per tap. The flag turns true when the touch is granted and stays on for a minimum time, so even a quick tap shows the pressed look. testOnly_pressed forces it on for a snapshot test.
hitSlop widens the area where a press can begin without moving the drawing or the layout: 10 on each side turns a 24-point icon into a 44-point target. Its partner pressRetentionOffset sets how far a finger may then drift before the press cancels. Where the slop overlaps a sibling drawn on top, that sibling gets the touch.
TouchableOpacity fades its content to activeOpacity, 0.2 by default. TouchableHighlight needs exactly one child and dims it over a black underlay, but only when a press handler is set. TouchableWithoutFeedback clones its only child, which must forward the touch props to a native view. The ripple of TouchableNativeFeedback is also Pressable's android_ripple.
Clear any highlight in both onResponderRelease and onResponderTerminate. Release means the finger lifted normally; terminate means the touch was taken away, by a scroll view that began scrolling or by the system. Returning false from onResponderTerminationRequest refuses another view's request for the touch, but the system can still take it.
When several views volunteer, the deepest wins, which is why a button inside a tappable card fires instead of the card. A parent overrides that only through the Capture variants, which run from the root down first. That is how a list still coasting from a fling takes your tap to stop scrolling, rather than pressing the row beneath.
PanResponder.create gives each callback a running gestureState (dx, dy, vx, vy) held inside that instance, so create it once in a ref: an instance rebuilt by a mid-drag re-render starts again from zero. Each move is handled on the JavaScript thread, and Animated.event must use useNativeDriver: false here, so a busy thread makes the drag stutter.
With the default 'never', a tap outside the focused input is captured by the scroll view only to close the keyboard, so Submit needs a second tap. 'handled' lets a child that responds keep its tap while taps on empty space still dismiss. Set it on every scroll view around the input, since the outermost one captures first.
Deciding whether a touch is a tap, a pan or nothing, and settling conflicts with scroll views, happens natively: UIKit gesture recognizers on iOS, the library's own orchestrator on Android. A busy JavaScript thread cannot delay activation. Where your callbacks then run is separate, and depends on Reanimated turning them into worklets.
Without a GestureHandlerRootView above it, a development build of gesture-handler 2.31 throws an error naming the problem; only a release build fails silently. The quiet case that survives testing is an Android Modal: it opens a separate native window, so its content needs its own root view even though nothing throws.
Tap fails unless the finger lifts within maxDuration, 500 ms by default, without drifting. LongPress activates after minDuration, also 500 ms, and Pan only once movement passes a threshold you tune with activeOffsetX. Events differ too: translationX and velocityX for pan, scale for pinch, radians for rotation.
In one detector, Race lets the first gesture to activate cancel the others, Simultaneous lets them run together, like pinch with rotate, and Exclusive ranks them. Exclusive(doubleTap, singleTap) holds every single tap until the double tap gives up. Gestures on separate detectors use calls such as requireExternalGestureToFail.
They stay on the UI thread only if Reanimated's Babel plugin made every callback of that gesture a worklet, as it does for functions written inline in the builder. One plain function, or .runOnJS(true), moves them all to the JS thread. From a worklet, reach React state with scheduleOnRN(setValue, v); Reanimated 4.3 deprecates runOnJS.
'none' removes the view and its whole subtree from hit-testing, and unlike CSS a child cannot opt back in with 'auto'. 'box-none' lets touches fall through the view itself while its children still respond, ideal for a full-screen overlay holding a few buttons. 'box-only' is the reverse: the view takes touches, its children never do.
E. StyleSheet and the style system
Because a style is a plain value, you build it with ordinary code: a conditional array entry, a function of props, or a width from useWindowDimensions() standing in for a media query. A key the native side does not know, such as a kebab-case 'background-color', is dropped without a warning, so let TypeScript check your styles.
In 0.85 create returns the object you pass, freezing entries only in development; nothing is registered or sent to native by ID. Its native payoff is typing: position: 'absolute' stays literal rather than widening to string. Under react-native-web each entry compiles once to CSS classes, where a plain object goes inline.
Merging is per key, so [styles.button, {padding: 8}] changes only the padding, and nested arrays flatten in order. In a reusable component, put the caller's style prop last so it can override your defaults. TypeScript allows false, null, undefined or '' in the array but not 0, so write count > 0 && styles.badge.
ViewStyle has no color key, so TypeScript rejects it and the native view ignores it rather than passing it down. Even the web build keeps the rule: react-native-web resets each outermost Text to black, so a color on a wrapping View cannot leak in the way CSS inheritance would. Theme text through a shared text component or context.
On iOS and Android nothing reports it: the string is unparseable, so the view ends up with no margin and only TypeScript catches the mistake. Write marginVertical and marginHorizontal for the two-value case. Precedence follows specificity, not order: marginTop beats marginVertical, which beats margin, wherever each sits.
A unit string is the trap: on iOS and Android width: '100px' fails to parse and the view lays out as if no width were set, yet react-native-web passes it to CSS, so it looks right in a browser. Bare numbers work everywhere because the web build appends px, and browsers scale a CSS pixel by the device pixel ratio too.
Margin and padding take percentages from the parent's width, even on the top and bottom, so paddingTop: '10%' in a 300-wide parent is 30. Offsets, flexBasis and gap accept them too, borderRadius and translateX measure them against the view's own box, and borderWidth, fontSize and lineHeight take numbers only.
On iOS nothing shows until shadowOpacity rises above its default of 0, and elevation is ignored. Android ignores offset, opacity and radius; its elevation also lifts the view in stacking order, and shadowColor tints the shadow only from Android 9. boxShadow takes one CSS-style value on both, though outer shadows need Android 9.
With [{rotate: '90deg'}, {translateX: 50}] the view lands 50 points lower, because the shift follows the already-turned axis; reverse the entries and it moves 50 to the right, turned in place. Each object holds exactly one key, and a transform never changes layout, so neighbors keep their places and the moved view can overlap them.
Each platform has its own names, label or systemBlue on iOS and paths like ?android:attr/textColor or @color/brand on Android, so choose with Platform.select, adding fallback names after the first. DynamicColorIOS({light, dark}) gives your own colors the light and dark switching of iOS system colors, but throws on Android.
Name tokens by role, text.muted or surface.raised, rather than by value such as gray500, so a dark palette swaps every value without touching a component. Styles built at module scope capture whichever palette existed at load, so a theme switch needs them rebuilt from current tokens, say in a hook memoized on the palette.
All of them produce the same style objects, so native layout and drawing cost the same; what differs is JavaScript work per render. A runtime CSS-in-JS library converts CSS text or prop-driven functions as components render, while a compiler such as NativeWind v4 emits StyleSheet.create objects at build time and applies them at runtime.
Every state is a value you track: pressed from Pressable's style function, focus from onFocus and onBlur kept in state, disabled from your own prop. Hover is effectively web-only: react-native-web's Pressable also passes hovered and focused, while 0.85 wires onHoverIn on iOS and Android only behind an off-by-default flag.
With no userInterfaceStyle in an Expo config, iOS pins the app to light, so useColorScheme never reports 'dark'; set 'automatic', which Android development builds honor only with expo-system-ui. Appearance.setColorScheme('dark') overrides the app but is lost on relaunch, so store the choice and reapply it.
F. Yoga layout, and where it diverges from web CSS
display takes only 'flex', 'none' and 'contents', the last added in 0.77. TypeScript rejects a web value such as 'block', and if one slips through, the native side logs it and keeps 'flex'. Inside a <Text>, though, flexbox gives way to text layout, so nested text wraps at the line end like inline content.
With a vertical main axis, justifyContent works vertically, alignItems horizontally, and flex: 1 fills leftover height rather than width, the reverse of the web habit. An icon meant to sit beside its label stacks above it until the parent sets flexDirection: 'row'; react-native-web's View keeps the column default.
Nothing in a row gives way, so a long title keeps its full measured width and shoves the badge past the edge. Put flexShrink: 1 on the title rather than the badge, and the title narrows, wrapping or truncating with numberOfLines. A web preview can hide the bug, because react-native-web leaves Text at the CSS default of 1.
On iOS and Android, Yoga reads flex: 1 as flexGrow: 1 with a flexBasis of 0, not 0%, and leaves flexShrink at 0. Only the web expands it to CSS's 1 1 0%. With a zero basis, a flex: 1 child inside a plain wrapper View collapses to zero height, because the wrapper is only as tall as its content; give the wrapper flex: 1 too.
It only distributes spare space, so justifyContent: 'center' on a View sized by its own content does nothing until that View gets flex: 1 or a fixed height. space-evenly makes every gap equal, edges included, while space-around leaves half-size gaps at the two ends.
Web flex items stretch too; here every view is a flex column, so a plain View spans its parent's width. Set alignItems: 'center' on the parent and children shrink to their content, so a divider given only a height collapses to zero width. In a row the stretch is vertical: a child with no height takes the row's full height.
It only moves a child across the cross axis; there is no justifySelf for the main axis. For that, use an auto margin: marginLeft: 'auto' in a row pushes the child and every later sibling to the far end. In a column with spare height, marginTop: 'auto' drops a footer to the bottom.
Children do stay on one line, but since flexShrink defaults to 0 they do not compress. A row of chips wider than the screen keeps every chip's size and runs past the edge. flexWrap: 'wrap' moves the overflow onto new lines, which pack against the top because alignContent defaults to 'flex-start'.
On the web an absolute element skips static ancestors, the CSS default, to find a positioned one. Every view here starts as relative, so the nearest wrapper anchors it, however plain. To reach an outer view, mark the views between position: 'static', added in 0.74, and put a full-screen overlay at the root or in a Modal.
TypeScript rejects position: 'fixed'. At runtime the native parser logs it and uses 'relative', so a header inside a ScrollView scrolls away with the rest. Put the header above the ScrollView as a sibling, or use stickyHeaderIndices for one that scrolls until it docks at the top, like position: sticky.
As of 0.85, Yoga has no grid mode, so display: 'grid' falls back to flex, and there is no float style. Build a card grid from a wrapping row whose items take percentage widths that leave room for any gap. For a long grid, FlatList with numColumns lays items out row by row, so staggered columns of uneven cards are out.
Added in 0.71, they space siblings only, never a child from the container's edge, so outer spacing still needs padding. Unlike margins, a gap needs no fix when the last child is conditionally left out. In a wrapping row, rowGap separates the lines and columnGap separates items within a line.
Views do not clip children by default on either platform, so an image filling a card with borderRadius keeps square corners until the card sets overflow: 'hidden'. On Android, elevation outranks zIndex among siblings: a view with elevation: 2 covers one with zIndex: 10 and no elevation, so raise the winner's elevation.
It is width divided by height, so 2 is twice as wide as tall, and since 0.71 '16 / 9' works too. A network image has no size of its own, so with only aspectRatio it takes its width from a default column's stretch. Under a parent with alignItems: 'center', neither side has a source and it shrinks to 0 by 0.
It fires on mount and after every layout change, with x and y measured from the parent, not the screen. A state update made inside it can land after that frame is painted, so anything positioned from it may visibly jump. For a tooltip or similar placement, measure the ref inside useLayoutEffect instead.
Yoga is C++ that React's commit calls synchronously, so no JavaScript of yours runs the layout math. Since 0.82 every app runs the New Architecture, where an ordinary update is laid out on the JS thread right after render. A huge tree's layout time therefore holds up your event handlers too.
G. Units, density and responsive dimensions
A 48-wide button covers 144 hardware pixels on a 3x iPhone and 96 on a 2x one, yet it looks the same size on both. Bitmaps are the part you manage yourself, by shipping @2x and @3x files so each density gets enough pixels. Fractional sizes are allowed, and layout snaps every edge to the nearest physical pixel.
getPixelSizeForLayoutSize(120) returns 360 on a 3x phone, the width to request from an image server. The ratio is not always a whole number: on a 2.625x Android phone, roundToNearestPixel(10) returns about 9.9, the nearest size that spans whole pixels, which keeps sizes you compute yourself on the pixel grid.
With allowFontScaling on by default, a larger system text size enlarges every Text and TextInput, while a fixed-height row stays put and clips the bigger label. Read the factor from useWindowDimensions().fontScale, cap a cramped spot with maxFontSizeMultiplier instead of disabling scaling, and test at the largest size.
It also updates when the user changes text size, a foldable opens, or an iPad window is resized. A width read once at module scope or baked into StyleSheet.create stays at its startup value, so after rotation that layout keeps portrait sizes. Call the hook in the component that lays out; it reports the window, not the screen.
On Android screen is the whole display, while window comes from the app's display metrics, which leave out the navigation bar. The status bar is subtracted only while it is opaque, so an edge-to-edge app's window includes it. On iOS they match except in iPad multitasking, and neither marks the safe area.
It is computed once as 0.4 snapped to the pixel grid, which is one physical pixel on screens below 3.75x: half a point at 2x, a third of a point at 3x. At 3.75x and above the same rounding lands on two pixels, so treat it as a style token for borders and dividers, not a number for layout math.
They arrive as four numbers, top, right, bottom and left, and change with orientation: a notched iPhone turned sideways reports left and right insets and no top one. Pad content by them but let backgrounds run underneath. useSafeAreaInsets() from react-native-safe-area-context reads them and throws without a SafeAreaProvider above it.
It depends on your target, not just the phone: an app targeting API 35 goes edge-to-edge on Android 15, and one targeting API 36, the default since React Native 0.81, cannot opt out on Android 16. Content then starts under the status bar and ends under the navigation bar, so pad headers and footers with safe-area insets.
H. Lists and virtualization
Rows that leave the render window are unmounted, so state kept inside a row component, such as an expanded toggle or a half-typed comment, is gone when the row scrolls back. Keep that state in the row's data or a store keyed by id. A ScrollView never unmounts children, which is why its memory grows with the content's length.
Without one, the list reads item.key, then item.id, and only then the index, logging a missing-keys warning. An index key binds identity to position, so after you delete row 3, row 4's data renders into the component still holding row 3's half-typed reply. Return a string id that stays fixed for the row's lifetime.
Destructure it as ({ item, index }); a callback that names its parameter item gets the whole wrapper, so item.title comes back undefined and the row renders empty. The separators object lets a pressed row call highlight() and unhighlight(), which flip the highlighted prop on the separators just above and below it.
Spacing set there scrolls with the rows, so paddingBottom lets the last row clear a tab bar, and flexGrow: 1 lets short content such as an empty state fill the viewport for centering. Layout props expose the mix-up: put alignItems or justifyContent in style and a development build throws, pointing at contentContainerStyle.
Return { length, offset, index } per row and the list stops measuring, which initialScrollIndex also requires. Without it, scrollToIndex toward a row that has not rendered throws unless you pass onScrollToIndexFailed. Use it only for truly fixed sizes, and count separator height in every offset, or the list jumps.
It defaults to 10, and those first rows are pinned: windowing never unmounts them, so returning to the top is instant, but an oversized value holds memory for the list's whole life. Size it to just fill the first screen. Setting initialScrollIndex turns the pinning off and renders from that index instead.
The unit is one viewport, and the default of 21 keeps up to ten screens of rows mounted above the visible one and ten below. Dropping it to around 5 saves memory and render work on long lists of heavy rows, at the cost of blank space whenever a fling outruns rendering.
It defaults to 10, and batches for rows far off-screen are spaced by updateCellsBatchingPeriod, 50 ms by default. A bigger batch fills blank space sooner but holds the JavaScript thread longer, so taps wait their turn; a smaller one keeps the app responsive and lets blank areas linger in fast scrolls.
In 0.85 FlatList turns it on by default on Android and leaves it off on iOS. Detached views still exist, which is why memory barely moves, and rows drawn with transforms or absolute positioning are the likeliest to come back empty, mostly on iOS. Turn it on per list and scroll-test before shipping.
Scrolling runs natively, so content keeps moving while JavaScript is still building rows; the blank is the gap between those two speeds. Cut per-row render cost first, with memoized rows, smaller images and no heavy work in renderItem, then tune the window props, or move to a list that recycles row views, such as FlashList.
FlatList is a PureComponent, so a new inline function on every parent render defeats its prop check and re-renders every mounted row. Hoist the renderer or wrap it in useCallback, and make rows React.memo components. If a stable renderer reads state outside data, such as a selected id, pass that value in extraData too.
Each entry in sections needs its own data array, and renderItem receives that section plus an index counted within it. Headers come from renderSectionHeader and stick to the top by default on iOS only; set stickySectionHeadersEnabled to decide for both. A section can override renderItem or keyExtractor for its own rows.
The unit is the visible length, so 0.5 fires with half a screen to go; always set it, since in 0.85 an unset value waits for the last 2 pixels. It fires once per content length and re-arms when that changes, so a loading footer appearing can start a second fetch; guard with an in-flight flag.
Each takes a component or an element and sits inside the scroll content, so the header and footer scroll with the rows. ListEmptyComponent appears only while data is empty, and separators go between rows, never above the first or below the last. Children nested inside the list tag are ignored.
numColumns builds the grid by grouping data into rows, and only for a vertical list: pairing it with horizontal throws. Changing it after mount also throws, so change the list's key to remount when the column count changes. columnWrapperStyle is where spacing between columns goes, and it throws on a single-column list.
I. ScrollView, keyboard and safe area
Every child mounts, lays out and keeps its native view, so first render and memory grow with the content rather than the screen; that suits bounded content such as a form or an article. Nest a FlatList in a vertical ScrollView and the cost returns, because the list stretches to full height and its windowing breaks.
It sizes itself from its parent, so if any ancestor is sized by its content (a wrapper View missing flex: 1 is the usual culprit), it stretches to the full height of its children, runs off the screen and has nothing left to scroll. Walk up the tree until every ancestor has flex: 1 or a fixed height.
'on-drag' hides the keyboard when a scroll begins; 'interactive' lets it follow your finger on iOS and acts like the default 'none' on Android. Taps are separate: with keyboardShouldPersistTaps at its default 'never', the first tap on a button only closes the keyboard, and 'handled' lets buttons fire at once.
In 0.85 the default is 0, and anything up to 16 ms switches throttling off, so iOS delivers onScroll on every frame and Android reads the same prop. Raise it, to 100 say, only when each event does expensive JavaScript work, because the throttle also paces scroll-driven animations running on the native driver.
padding adds bottom padding equal to the keyboard overlap, height shrinks the view and position slides an inner container up. The overlap is measured from the view's own frame, so one placed below a navigation header needs that height in keyboardVerticalOffset. iOS adjusts as the keyboard rises; Android, only after it has appeared.
On iOS it pads by the safe-area insets, but elsewhere it renders a plain View, so an edge-to-edge Android screen slides under the status bar. React Native 0.81 deprecated it, and in 0.85 importing it logs a warning. The safe-area-context library's SafeAreaView and useSafeAreaInsets work on both platforms and let you choose edges.
Pass a <RefreshControl> to a scroll view's refreshControl prop, or give a FlatListonRefresh and it builds one, provided refreshing is a boolean; otherwise it throws. The spinner is controlled, so set refreshing true when the reload starts and false when it ends. Pull-to-refresh works only on vertical scrolling.
J. Platform differences and platform-specific code
Only ios and android come from React Native itself: windows and macos come from Microsoft's out-of-tree packages, and react-native-web reports web. A two-way test such as Platform.OS === 'ios' ? a : b therefore files web and desktop under Android, so branch with Platform.select and a default.
On iOS and Android it tries the current platform's key, then native, then default; react-native-web checks only web and default. When nothing matches you get undefined rather than an error. Values can be anything, from a number to a whole style object or a component to render.
Import ./Banner and Metro tries Banner.ios.tsx or Banner.android.tsx, then Banner.native.tsx, then plain Banner.tsx. If one platform's file is missing and no plain fallback exists, only that platform's bundle fails to resolve, so keep both files' exports identical and build each platform.
On iOS and Android, Metro checks Banner.native.tsx after any platform-specific file, while a web build skips .native files because Expo's Metro switches that lookup off for web. The plain Banner.tsx then becomes the web version, which lets you swap a native-only module for a browser one without touching imports.
On Android it is the API level, so Android 15 reports 35, and a test like Platform.Version >= 15 passes everywhere because React Native 0.85 itself requires API 24. On iOS it is a string such as '18.2'; parse it with parseInt(Platform.Version, 10) before comparing, since as strings '9' sorts above '17'.
Handlers run newest first, and the first to return true stops the rest; if none does, the default runs and finishes the activity. addEventListener returns a subscription whose remove() belongs in your cleanup. On iOS the module does nothing, and in 0.85 ReactActivity keeps it working under Android 16's enforced predictive back.
android_ripple is read only on Android, and only when it sets color, borderless or radius, so an empty {} draws nothing. iOS ignores it; there you fade through the pressed state that a function-valued style receives, as TouchableOpacity does at 0.2 opacity by default. borderless: true lets the ripple spill past the view's edges.
fontFamily: 'monospace' gives a fixed-width face on Android but means nothing to iOS, where an unknown family quietly falls back to the system font; iOS instead understands generic names such as 'ui-monospace', which Android ignores. Pick names with Platform.select, or bundle the same font file with the app for both.
Android pads each Text above and below for tall ascenders and descenders, so a label centered in a fixed-height pill can sit visibly off-center; the Android-only includeFontPadding: false with textAlignVertical: 'center' fixes it. The default face differs as well, San Francisco on iOS and Roboto on stock Android, so check wrapping on both.
On Android you declare the permission in the manifest and request it at runtime, and since Android 11 a second denial is permanent: the dialog stops appearing. On iOS the system asks once, when your code first requests access, showing your Info.plist purpose string, and a missing string makes access fail and can crash the app.
K. Animation
Each frame the new number goes straight to the native view, so your component does not re-render while it moves. The catch is that render cannot read the current position: subscribe with addListener, or call stopAnimation(cb) to receive the value where it halted. setValue jumps at once and cancels any running animation.
The prebuilt set is Animated.View, Text, Image, ScrollView, FlatList and SectionList; anything else, such as Pressable or an SVG shape, goes through Animated.createAnimatedComponent. Call that once at module scope, since calling it during render makes a new component type each time and React remounts the subtree.
timing runs for a fixed duration along an easing curve, 500 ms ease-in-out by default. spring has no duration: it simulates a spring pulling toward toValue, stops once at rest, takes a gesture's release velocity, and overshoots slightly at its defaults. decay has no target, only a starting velocity that bleeds away, which suits a fling.
The whole animation goes to native once, at start(), so a JavaScript thread busy parsing data cannot stall it. There is no default, and leaving the option out logs a warning. Once a value has run natively it stays native: a later JavaScript-driven animation on it throws, so keep one driver per value.
In 0.85 the allowlist covers opacity, transforms, filter, colors, border radii, zIndex, elevation and two iOS shadow props, all of which change without a new layout pass. Animate width or top with it and a development build logs a console error naming the property; scale or translate instead, or use the JavaScript driver.
Outside inputRange the default extrapolation is extend, so the mapping keeps going: a collapsing header tied to scroll shrinks past its minimum when the user pulls further. Add extrapolate: 'clamp' to hold the end values. Outputs like '0deg' and '360deg' work when every entry shares its units, and inputRange must never decrease.
sequence starts each step when the previous one finishes and drops the rest if one is stopped. parallel stops every member once any is interrupted, unless you pass stopTogether: false, and stagger is a parallel with growing delays. loop repeats forever by default, and only around a single native-driven animation does it stay on the UI thread.
Each re-render swaps in a fresh value at its starting number, so the view snaps back while the running animation keeps driving the abandoned one. Create it once with React Native's useAnimatedValue(0) or a lazy useState(() => new Animated.Value(0)); useRef(new Animated.Value(0)).current works but still builds a throwaway value each render.
Call LayoutAnimation.configureNext() right before the state change and the next commit's size and position changes animate, with no per-view code. On Android in 0.85 the old setLayoutAnimationEnabledExperimental(true) call is a no-op that only warns; the native enableLayoutAnimationsOnAndroid feature flag decides, and it is off by default.
The pending config belongs to the commit, not to a view, so views in unrelated components and siblings pushed aside by your change animate too. It is also one-shot: the next commit consumes it, and a second configureNext before that commit replaces the first instead of stacking with it.
A worklet is a function marked 'worklet', or a callback the Babel plugin recognizes such as the one given to useAnimatedStyle, and it runs in a separate JavaScript runtime on the UI thread. To set React state from one, call scheduleOnRN; react-native-worklets 0.8 deprecates the older runOnJS. On the web, worklets run as plain functions.
L. The New Architecture: Fabric, TurboModules, JSI, bridgeless
Only JSON-style data could cross, meaning strings, numbers, booleans, arrays and plain objects, and replies came back later through callbacks or promises, apart from opt-in blocking methods. In exchange, the UI thread never waited on JavaScript. Since 0.82, apps run on the New Architecture only, so this path cannot be switched back on.
Every message waited its turn in a single queue of serialized calls, so a stream of scroll events or a long base64 image string could delay the update a gesture was waiting on. Lists that flashed blank rows during a fast fling were the classic sign. JSI removed the copying, but a JS thread kept busy still misses frames.
It is engine-neutral, so the same native code runs against Hermes or JavaScriptCore. A call through it runs synchronously on the calling thread, and a runtime must never be used from two threads at once, so native work finishing on a background thread posts its result back through a CallInvoker, which schedules it on the JS thread.
Reading one of its properties calls your C++ get at that moment, so values are produced on demand instead of copied in advance, and assigning calls set, which throws a TypeError unless you override it. Its destructor runs only when the garbage collector finalizes the wrapper, so release files or sockets through an explicit method.
A module is built the first time JavaScript looks it up, which a spec file does at its top level, so the cost lands when that file is first imported, unless the module opts into eager initialization. Launch time stops growing with each native package, but a slow constructor now stalls whichever screen imports it first.
Codegen runs as part of each native build, so a spec edit takes effect only after rebuilding the app, never on a Metro reload. On Android it emits an abstract class, which turns a method you forgot to implement into a compile error; on iOS it emits an Objective-C protocol, where the compiler only warns.
React keeps a direct C++ pointer to each host component's shadow node, the record of its props and layout, where the old design sent create and update commands to a Java or Objective-C module holding its own copy of the tree. Native code that still schedules work through that module silently does nothing: in 0.85, Android's UIManagerModule.addUIBlock is an empty method.
Only the changed node and the chain of parents above it are copied; every untouched subtree is shared by pointer between the old and new trees, so a small update stays cheap. Because no committed tree is ever edited in place, the UI thread can read one while JavaScript builds the next, and the renderer can diff any two versions.
Render calls your components and builds a C++ shadow node, a record of props and layout, for each host component such as View; your own components never get one. Commit runs Yoga layout and promotes the finished tree as the next to show. Mount diffs it against what is on screen and applies the changes to native views on the UI thread.
The UI thread applies a commit's whole list of view mutations in one synchronous pass, so nothing draws halfway through it. When several commits arrive before the next mount, the renderer can diff straight from the mounted tree to the newest and skip the versions in between. Atomic is not instant, though: an oversized batch makes that frame late.
Inside useLayoutEffect you can read a host view's geometry with measure, or with getBoundingClientRect() on the ref since 0.82, and set state within the same frame, so a tooltip appears in its final spot instead of jumping as it does when positioned from onLayout. It works only on refs to host components such as View.
React's render and the commit, Yoga layout included, run on the JS thread, and only the UI thread may create or change native views, so it runs the mount; an urgent event can push the whole pipeline onto it. Async native module methods run on background threads and must hop to the UI thread before touching a view.
It became the default under the New Architecture in 0.74. Globals such as timers are installed straight from C++ instead of by a JavaScript module booted over the bridge, which shortens startup. Libraries written against RCTBridge or Android's CatalystInstance keep working through interop layers, though CatalystInstanceImpl itself is gone from the 0.85 sources.
A View that sets only margin, padding or flex is merged away, so a native view inspector shows fewer views than your JSX. A background color, border, opacity, transform, touch handler or testID keeps it, and collapsable={false} forces it when native code must find that exact view.
A fix or optimization in the core reaches every platform at once: view flattening began as an Android-only trick and now runs on iOS as well. What stays per platform is the layer that creates host views and measures text and text inputs, and on Android that layer still pays a crossing through JNI, Java's interface to C++.
M. Hermes and the JavaScript environment
It has been the default since 0.70, and 0.84 made Hermes V1, with an improved compiler and VM, the default. React Native 0.81 dropped its own JavaScriptCore option, which now comes from a community package. HermesInternal.getRuntimeProperties()['OSS Release Version'] reports the exact engine build to cite in a bug report.
A release build ships bytecode that hermesc compiled during packaging, but in development the app loads plain JavaScript from Metro and Hermes compiles it on the device. Hermes keeps no source text by default, so fn.toString() returns something like function f(a0, a1) { [bytecode] }, breaking libraries that read a function's text.
In a release build, launch skips parsing because the code arrives compiled. The engine also reads functions from the bundle file as they run, instead of building the whole program in its heap. Neither effect speeds up a CPU-bound loop, so judge an engine change by cold-start time and memory, not by a benchmark.
Hermes V1, the engine in 0.85, still interprets bytecode, so sustained math can lose to JavaScriptCore on Android, where its JIT turns hot functions into machine code. On iOS that edge vanishes: apps cannot create executable memory, so JavaScriptCore runs interpreted too. Move heavy number crunching into native code.
Young objects are collected in brief pauses on the JS thread, while most marking and sweeping of old objects runs on a background thread. On most 32-bit devices that work runs incrementally instead, and HermesInternal.getRuntimeProperties().GC names the mode. Heavy allocation per frame still triggers young-generation pauses.
Each bytecode file records a format number, and the engine rejects any mismatch with Wrong bytecode version. Expected 98 but got 96. In 0.85.3 Hermes V1 reads format 98 while the legacy engine you can opt back to reads 96, so switching engines strands old updates. Tie each update to the native build it was compiled for.
React Native's startup code installs these globals, not the engine, so frameworks can replace them. Expo swaps in its own fetch and a web-standard URL parser. A React Native CLI app in 0.85 keeps a simpler URL whose hostname is empty for a deep link like myapp://profile/42, so test link parsing in the setup you ship.
The only Intl constructors are Collator, DateTimeFormat and NumberFormat, so Intl.RelativeTimeFormat or Intl.ListFormat needs a polyfill. iOS has less still: NumberFormat lacks formatToParts, so calling it throws a TypeError, and notation: 'compact' is ignored, printing 1500 as 1,500 in en-US.
Hermes carries its own agent for debugger, runtime and profiler messages, and React Native layers network and tracing data around it. Heap snapshots and CPU samples therefore come from the engine running on the device. Pausing at a breakpoint halts the JS thread itself, so taps and due timers wait until you resume.
N. Native modules and native components
Common reasons are a vendor SDK with no JavaScript port, like a card reader's; a system feature nobody has wrapped; or CPU-heavy work that should leave the JS thread. Check the Expo SDK and community packages first, since your own module means two implementations, a native rebuild per change and upkeep across upgrades.
Codegen reads only files named with a Native prefix, so a spec saved as Haptics.ts is silently skipped. The name passed to TurboModuleRegistry.getEnforcing must match what the native side registers: when the binary lacks it, importing the spec throws, while TurboModuleRegistry.get returns null so you can feature-detect.
The generated C++ maps each method name to its argument count and declared return kind; on Android it also writes the JNI signature that reaches your Kotlin or Java method. On iOS, an object-literal parameter becomes a generated struct with typed getters, while one typed Object arrives as a loose dictionary you must check yourself.
Numbers cross as doubles both ways, so a native 64-bit id above Number.MAX_SAFE_INTEGER arrives with wrong low digits unless you send it as a string. Objects are copied key by key, so a class instance lands as a plain map of its fields without methods. A Date or Map has no enumerable keys and arrives empty.
The spec's return type decides. A method returning Promise or void is queued to a native thread and frees JavaScript at once. One returning a string or boolean instead blocks the JS thread until the native code returns. Keep synchronous methods to quick in-memory reads, and put disk or network work behind a promise.
NativeEventEmitter calls the module's addListener on every subscription and removeListeners(1) on every remove(), so native code can start a sensor at the first listener and stop it at zero. A subscription never removed keeps it running, and on iOS RCTEventEmitter drops events sent while the count is zero.
Codegen recognizes the spec only when its file name ends in NativeComponent, and props extending ViewProps get layout and style for free. Imperative calls such as seek() are not props; they go through codegenNativeCommands. On iOS, unmounted views return to a recycle pool, so reset your view's state in prepareForRecycle.
On Android, wrap view work in UiThreadUtil.runOnUiThread; touching a view from another thread can throw CalledFromWrongThreadException. On iOS, wrap it in dispatch_async(dispatch_get_main_queue(), …), or chain .runOnQueue(.main) in an Expo module. Synchronous methods execute on the JS thread and need the hop too.
CocoaPods links native packages when you run pod install, and Gradle when it builds, so a binary built before you added the package lacks it and getEnforcing reports it could not be found. To keep a package off one platform, set that platform to null for it in react-native.config.js, which Expo's autolinking also reads.
Without a spec, the Swift or Kotlin definition is the contract, so a drifted TypeScript declaration compiles and a bad argument fails only when called, with The 1st argument cannot be cast to type. Function runs synchronously on the JS thread; AsyncFunction returns a promise from another thread. Prefer Turbo Modules for C++ code.
O. Performance
At 120 Hz the window shrinks to about 8.3 ms. The UI thread must finish its share of every frame, but JavaScript owes frames only while it drives motion, so a native scroll keeps its pace while JS is busy. A single JS task longer than a frame, such as parsing a large response, can hold back the next tap by up to its full length.
A busy JS thread lets native scrolling and native-driven animations run while taps and Pressable feedback wait. A blocked main thread freezes everything, and on Android, input ignored for five seconds raises the Application Not Responding dialog. In an Android trace, check whether the main thread or mqt_v_js stays busy across frames.
The Dev Menu's Show Perf Monitor item turns it on. iOS graphs UI and JS rates beside RAM. Android shows the UI rate plus counts of dropped frames and stutters of four or more frames, but hides the JS rate under Fabric, the only renderer since 0.82. It runs only in debug builds: use it to find the struggling thread, then measure in release.
Development mode also swaps in React's development renderer, which runs extra checks and keeps debugging data on every render, and it leaves the inspector attached. Time screens in a release build made with npx expo run:android --variant release, npx expo run:ios --configuration Release or the React Native CLI's --mode flag.
Unless you pass a single string, each call formats its arguments, walking objects up to 10 levels deep, and that work runs in release builds too. In development the inspector also keeps the last 1,000 messages along with the values logged, so objects logged every frame stay alive.
Every render spends JS-thread time that taps and JS-driven animations wait behind. It reaches native views only when an attribute value changes, because React Native diffs old and new props and leaves an identical host view alone. A changing key, which remounts a native subtree, turns that waste into real view work.
Decoded, a 12-megapixel photo takes about 48 MB at 4 bytes per pixel. On Android, Image decodes a remote picture at full size unless you set resizeMethod="resize", since the default auto downsamples only local files. iOS decodes a copy near the view's pixel size, yet both platforms download and parse the whole file.
The native shadow, elevation, is cast from the view's outline, its border box with rounded corners, so it cannot follow an icon or a transparent image. A content-shaped shadow needs filter with dropShadow, which requires Android 12 and draws the view into a separate layer and blurs it whenever the view is drawn.
Each top-level require moves to the first place its value is read, so a module runs on first use or never. React Native CLI's Metro config enables it, while Expo's sets inlineRequires: false; turn it on in metro.config.js and compare cold starts in release. Keep setup that patches globals as a bare import in your entry file.
Since 0.82 runAfterInteractions ignores interaction handles and runs the task via setImmediate, which is a microtask in 0.85. It is gone in 0.87, so chunk heavy work inside requestIdleCallback, stopping when timeRemaining() hits zero, which happens as soon as other work such as a touch event is queued.
Bytecode and a leaner bundle shorten the load, inline requires and lazily loaded screens shorten module evaluation, and drawing only what the first screen shows shortens the first render. Time each phase in a release build, since in development the app pulls source from Metro and compiles it on the device.
P. Debugging and developer tooling
Since the protocol is the one Chrome speaks, the window you debug in is a customized Chrome DevTools frontend. Metro relays the session through an inspector proxy, so quitting Metro or installing a new native build ends it. It arrived in React Native 0.76, works only with Hermes, and does not support attaching through chrome://inspect.
Deprecated in 0.73 and removed in 0.79, it shipped your JavaScript to Chrome's V8 engine on your computer and carried every native call over the network to the device. Code that calls native modules synchronously through JSI, the direct interface the New Architecture uses, could not work that way, one reason the mode was retired.
An edit climbs the import graph to the nearest file that exports only components; those re-render with their useState and useRef values kept. A full reload happens only when the climb leaves the React tree, as with a module your entry file imports directly, and class components or mixed-export files lose state on every edit.
Errors raise a red badge with a count and warnings a yellow banner, and neither exists in a release build. LogBox.ignoreLogs hides every message that contains your string anywhere, so a broad pattern can bury an unrelated warning; the text still reaches the DevTools console, so read it there before you ship.
Hermes reports frames such as p@1:132161, a minified name plus a bytecode offset, and npx metro-symbolicate translates them with the source map from that exact build, since any other build gives wrong lines. Android release builds generate one by default, while iOS needs a SOURCEMAP_FILE export in the bundling build phase.
Metro reuses a file's cached transform until the file or Metro's settings change, so editing something else a transform reads, such as adding a Babel plugin in a React Native CLI project, can leave old output running. Restart with --reset-cache (React Native CLI) or --clear (Expo CLI), then expect one slow start.
Shake a physical device, press Ctrl+Cmd+Z in the iOS Simulator, or press Cmd+M in an Android emulator (Ctrl+M on Windows and Linux); adb shell input keyevent 82 also works. From the dev server's terminal, d opens it under the React Native CLI and m under Expo CLI. Release builds have no menu at all.
When the app vanishes without a red screen, read the native log: adb logcat filtered to the ReactNative and ReactNativeJS tags on Android, or the Xcode console when you run from Xcode. On Android your console output lands in that same stream under ReactNativeJS, so you see what JS did just before the native trace.
Without any boundary, a render error removes the entire UI, which in a release build means a blank screen and no LogBox to explain it. Wrap each screen, or a risky widget, so one failure leaves the rest usable, and give the fallback a retry that clears the boundary's error state.
Catch the rejection and store it as state the screen renders, a message with a retry button, rather than letting it fail unobserved. React Native tracks unhandled rejections only in development, where LogBox shows Uncaught (in promise, id: …); release builds report nothing by default, so a screen waiting on it can stay stuck loading.
A reporter installs handlers for uncaught JS errors and native crashes and attaches breadcrumbs, such as recent screens, taps and requests, to each report. A native crash ends the process before anything uploads, so Crashlytics, for one, sends it on the next launch, and JS frames stay minified until you upload that build's source map.
fetch resolves even for a 500 and rejects only when the network fails, so check response.ok before calling a request done. Retry timeouts, dropped connections, 5xx and 429 responses with growing, randomized delays and a cap; a 400, 403 or 404 will fail the same way again, so show the error and keep any cached data on screen.
getBy* throws when nothing or several elements match, and queryBy* returns null for zero matches so you can assert that something is absent. findBy* returns a promise that keeps retrying for up to a second by default. Role queries see only accessibility elements, so a plain View needs accessible before getByRole finds it.
Jest runs with no device, so anything that reaches native code needs a stand-in: jest-expo mocks the native half of the Expo SDK, and AsyncStorage ships an in-memory mock under jest/async-storage-mock. React Navigation advises the opposite for itself: render a real navigator and mock only native pieces like gesture handler.
An act warning means a state update landed after the test stopped watching, from a timer or a promise that settled late; await the result with findBy* or waitFor instead of wrapping lines in act until it goes quiet. waitFor retries every 50 ms for up to a second, and only a thrown error keeps it waiting.
Each layer up costs more but catches what the layer below cannot: only an E2E run on a real build notices a broken native module or a crash at launch, so spend those on sign-in, payments and other vital flows. A snapshot treats its first render as correct, and a large diff tempts reviewers to accept it with --updateSnapshot unread.
There is no jsdom, the simulated browser that web tests run in: the tree holds host elements like View, Text or TextInput, each carrying the props that would reach the native view. No native code runs, so nothing is laid out and native-only state such as focus is invisible; assert with RNTL matchers such as toBeOnTheScreen().
That split holds only in development. In a release build, an uncaught fatal JS error reaches the native exception manager, which throws a JavascriptException on Android and an RCTFatalException on iOS, ending the process too. A native crash never passes through JS, so its trace lives in logcat, the Xcode console or a crash reporter.
Q. Metro, bundling and assets
The stages overlap: a file's imports are known only after it is transformed, so resolving and transforming run together across worker processes. The serializer then wraps each module and swaps every import path for a numeric id, which is why require needs a literal string the bundler can follow at build time.
Dynamic import() is accepted, but React Native splits on it only in development, and a release build still packs every reachable module into one file inside the app. Startup pays for evaluation, not download: Hermes loads its bytecode on demand, and React.lazy on screen components keeps their modules from running until first render.
Metro follows a symlink to its real path and sees only files under projectRoot or watchFolders, so a linked workspace package outside the app cannot resolve until its folder is watched. Lookups then start from that real path, which is how a package carrying its own node_modules/react puts a second React in the bundle and breaks hooks.
Metro runs every file through Babel with React Native's preset, or babel-preset-expo in an Expo app, and types are stripped rather than checked, so a TypeScript error still bundles and runs; tsc has to run on its own. How far syntax is lowered depends on the engine profile, and for Hermes V1 the preset keeps class syntax instead of compiling it away.
Always require the plain name, such as check.png, through a literal path, since the bundler gathers the variants at build time. Without an exact match the next denser file wins, and a screen denser than all of them gets the largest one scaled up. The default size in points is the smallest file's pixels divided by its scale.
A sourceExts file is compiled and run as a module, while an assetExts file is copied into the app and imported as an asset reference, so SVG, an asset by default, must move between the lists before a custom transformer can turn it into a component. mergeConfig replaces arrays, so spread the defaults or js and ts files stop resolving.
A debug build asks Metro, on port 8081 by default, for the bundle on every load, which is what makes reloads and Fast Refresh possible. A USB-connected Android phone reaches it through adb reverse tcp:8081 tcp:8081, or the phone joins the computer's Wi-Fi network. A release build carries a bundle made at compile time, so a JS change needs a new build or an over-the-air update.
R. Accessibility
The group becomes one focus stop, and unless you give it a label its name is every Text inside it joined with spaces, so a card reads as one sentence instead of five fragments. Touchables such as Pressable are grouped this way already, and on iOS a control nested inside a group can no longer be reached on its own.
Set one wherever the visible content does not explain itself, such as an icon-only button, which has no Text to borrow from. Keep it to a word or two and leave out the control type, since the role is announced separately; aria-label is an alias for the same prop.
A Pressable is focusable by default but carries no role, so a screen reader reads its text without saying it is a button. header lets users jump between section titles, and the newer role prop, with values such as heading and img, wins when both props are set.
Kept in state, disabled or checked is spoken by the platform in the user's own language, separate from the name, and tests can query it, as in getByRole('button', { disabled: true }). Pressable copies its disabled prop into this state for you, and aria-disabled, aria-checked and their siblings set the same fields.
Write it as a result in the third person, such as "Opens your order history", not as an instruction like "Tap to open": it says what will happen, not which gesture does it. VoiceOver users can switch hints off, while React Native's Android hints have no off switch, so anything a user must know belongs in the label.
accessibilityLiveRegion, and its aria-live alias, work only on Android; for VoiceOver, call AccessibilityInfo.announceForAccessibility() with the message when the content changes. Use polite for routine updates such as a cart count, and save assertive, which cuts off whatever is being spoken, for something urgent like a lost connection.
isScreenReaderEnabled() returns a promise, so your first render runs before the answer arrives, and the user can switch the reader on mid-session; pair the query with a screenReaderChanged listener and remove that subscription on unmount. On Android, isAccessibilityServiceEnabled() is broader, counting any assistive service rather than only TalkBack.
Core Animated never checks the setting, so read AccessibilityInfo.isReduceMotionEnabled(), listen for reduceMotionChanged, and swap large movement for a fade or an instant change. Reanimated 4.3 animations honor it by default but read it once at startup, so a change made mid-session waits for a restart.
Apple asks for at least 44 by 44 points and Android for 48 by 48 dp, measured on the tappable area rather than the drawn icon. hitSlop enlarges that area without moving anything, but it never reaches past the parent's edges and loses to an overlapping sibling, so a cramped row needs padding instead.
aria-hidden covers both platforms in one prop: React Native turns it into accessibilityElementsHidden on iOS and sets importantForAccessibility to no-hide-descendants on Android. It hides the whole subtree, so put it on the decorative wrapper, never on a parent that also holds a working control.
It applies to the view and everything inside it, so set it on the image itself rather than on a card whose text and background should still invert. The prop is iOS-only and React Native offers no Android counterpart; to adapt other content, AccessibilityInfo.isInvertColorsEnabled() reports whether the setting is on.
An error marked only by a red border vanishes for someone with red-green color blindness, so add an icon or a message, and mark a selected tab with weight or an underline as well. For contrast, WCAG's AA level asks for 4.5:1 on regular text and 3:1 on large text, the same thresholds Android's guidance uses.
By default, screen readers order elements by their on-screen layout, top to bottom in reading direction, while the view tree decides what is grouped, so placing related views under one parent is the main lever. experimental_accessibilityOrder, experimental in 0.85, takes an explicit list of nativeIDs, and any accessible descendant left off that list is never focused.
The iOS Simulator has no VoiceOver, so iPhone checks need a device, and Xcode's Accessibility Inspector only shows what each element exposes. Some props work on one platform only, live regions and accessibilityLabelledBy on Android and magic tap on iOS, and VoiceOver lets users silence hints that Android always reads.
An audit can confirm that labels, roles and contrast exist, but not that a screen-reader user can finish checkout, or that text at the largest system size, which Text follows by default, still fits a fixed-height row. Watch people with different needs complete real flows, and recheck after each release, since one refactor can undo the work.
S. Navigation
React Native ships no navigator, so the library you install decides which screens exist, what back means and how a URL maps to them. React Navigation is the common choice, and Expo Router builds its routes from files. Whatever you pick must also own Android's back button, because a press that nothing handles exits the app.
In React Navigation 7, navigate only updates the params of a screen that is already focused and otherwise pushes it, push always adds another instance, and returning to an earlier screen takes popTo. The native stack uses iOS's own swipe from the left edge, which gestureEnabled can turn off, and Android's system back, button or gesture, calls goBack for you.
Pushing another screen or switching tabs keeps the old one mounted, and only popping unmounts it. Work that should pause while a screen is hidden, such as polling or a camera feed, belongs in useFocusEffect, whose cleanup runs on blur, and useIsFocused lets rendering react to focus.
A function or class instance in params triggers React Navigation's warning about non-serializable values, because state persistence and deep links cannot rebuild it from text. Send results back with popTo and new params, and look full objects up by id from a store so the screen never shows a stale copy.
Two cases need handling: a link that launches the app, read with Linking.getInitialURL(), and one arriving while it runs, delivered as a url event; a router's linking config covers both. A link into a nested screen builds only that path, so set an initialRouteName to give back somewhere to go, and expect :id segments to arrive as strings.
With it installed, React Navigation and Expo Router detach screens you cannot see from the native view hierarchy to save memory, while their React state stays mounted. Setting freezeOnBlur, or calling enableFreeze(true), also stops hidden screens from re-rendering, and version 4.25 dropped support for the legacy architecture.
A file's path is its URL, so every screen can be deep-linked without a separate linking config. _layout.tsx chooses the navigator for its folder, [id].tsx captures a segment, and a (group) folder shares a layout without adding to the path; keep non-screen components outside the routes folder, or the router treats them as routes.
A modal route lives in navigation state, so a deep link can open it, back or the iOS swipe-down closes it, and the screen beneath stays mounted; React Navigation recommends declaring modals in the root stack. <Modal> is local UI shown by toggling visible, which defaults to true, and on Android its onRequestClose receives back instead of BackHandler.
A stack answers how to go back, tabs answer which section you are in, and a drawer reaches many destinations from a menu. Nesting decides what stays visible: tabs inside a stack's first screen let pushed screens cover the tab bar, while a stack inside each tab keeps the bar visible and a separate history per tab.
Options merge from the navigator's screenOptions through a group's down to the screen's own options, with the most specific winning, and each level can be a function of route and navigation. A title that depends on loaded data, or a header button that calls into the screen, goes through navigation.setOptions from inside the component.
T. Device APIs: networking, storage, permissions and app state
Requests go out through the platform's HTTP stack, OkHttp on Android and URLSession on iOS, with no browser in between to enforce CORS, so a missing Access-Control-Allow-Origin header changes nothing. The same call fails under react-native-web, and since any non-browser client can reach your API, CORS never protected it.
The failure surfaces as a generic network error with no status code, so it reads like an outage. Fix TLS on the server if you can; otherwise exempt one host with a domain-config in Android's network security config or an ATS NSExceptionDomains entry, because disabling ATS with NSAllowsArbitraryLoads must be justified at App Review.
A suspended app runs no JavaScript, so nobody handles incoming messages, and iOS may reclaim the socket meanwhile, surfacing the failure only after you return. Android's Doze mode cuts network access on an idle device. Treat the socket as foreground-only, closing it on background and reopening on active, and use push for a user who left.
Values must be strings, so objects go through JSON.stringify on the way in and JSON.parse on the way out, and a missing key resolves to null. Passing undefined as a value throws instead of deleting; call removeItem. On Android the database is capped at 6 MB by default, so large caches belong in files.
With expo-secure-store, Android keeps the key in the Keystore and the encrypted value in SharedPreferences. An iOS Keychain item survives uninstalling the app, so a reinstall can inherit an old session unless a first-run flag in ordinary storage prompts a wipe. A requireAuthentication value reads as null once a new fingerprint or face is enrolled.
On iOS the document directory goes into iCloud and computer backups and the cache does not, and iOS clears the cache only under low storage while your app is not running. Keep user-created files and queued uploads in Paths.document, re-downloadable media in Paths.cache, and check that a cached file exists before reading it.
In expo-file-system's File API for SDK 56, text(), bytes() and File.downloadFileAsync() return promises, but write(), info(), delete(), exists and size are synchronous. The all-promise style, readAsStringAsync and its relatives, moved to expo-file-system/legacy in SDK 54, and the copies left in the main entry throw when called.
Check canceled before touching assets; a dismissed picker is an ordinary result. Expo's image picker returns a copy in your app's cache, so move files you mean to keep, and on Android call getPendingResultAsync() in case the OS killed your activity mid-pick. An iOS app that only saves can use the add-only NSPhotoLibraryAddUsageDescription key.
Since Expo SDK 56 the global fetch rejects React Native's { uri, name, type } form-data part with Unsupported FormDataPart implementation; append an expo-file-system File, or call file.upload(), which sends from disk natively. Downscale photos to the size the server shows, and delete your temporary copy after a successful upload.
Android never reports inactive; instead its blur and focus events fire when the notification shade covers your app while the state stays active. Android also reports background whenever another Activity sits on top, even a picker or autofill sheet your app opened, so foreground logic can re-run after a photo pick.
Undeclared, both platforms answer false even when the app is installed: iOS needs the scheme in LSApplicationQueriesSchemes, and apps targeting Android 11 (API 30) need a matching <queries> intent. openURL needs neither, so for an https link skip the check, call it directly and handle the rejection.
A launch link arrives before any listener of yours exists, which is why it is read once at startup. On Android, warm delivery depends on launchMode: the templates' singleTask hands the link to the running activity as a url event, while standard opens a second copy. Expo Router and React Navigation's linking handle both paths.
Since Android 11 there is no checkbox: two taps on Deny make the refusal permanent, and later request() calls resolve never_ask_again without showing anything, so a button that keeps asking looks broken. When the user asks for the feature again, explain and offer Linking.openSettings(), the only place left to grant it.
For the camera, microphone or photo library, the OS kills the process outright, so no JavaScript try/catch can intervene, and App Review expects the text to describe your real use of the data. In Expo, set it with a module's config plugin or ios.infoPlist; Info.plist ships in the app bundle, so only a new build adds one.
React Native's navigator holds little more than product: 'ReactNative', so navigator.clipboard and navigator.vibrate are undefined on a device, and code that worked under react-native-web throws a TypeError. Reach for a module such as expo-clipboard, expo-haptics or expo-notifications, whose native half must be in your build.
APNs issues a new token after a backup restore or a move to a new phone, and an Android reinstall may change an Expo token, so fetch it every launch and upsert it per device on your server. Delete it on a DeviceNotRegistered error, and on Android 13 create a notification channel first, or no permission prompt appears.
In the foreground nothing appears unless setNotificationHandler returns a behavior within three seconds; the default is silence. In the background or after the process dies, the notification is shown natively and none of your JavaScript runs, so work without a tap needs a data-only message and a background task, which the OS runs sparingly.
Put a route or an id in data, not the content, since Expo's push payload is capped at 4,096 bytes. A tap that launches the app lands before component listeners mount, so read it with useLastNotificationResponse(), navigate once the navigator is ready, then call clearLastNotificationResponse() so a remount does not route twice.
A local notification is scheduled on the device with scheduleNotificationAsync, so it needs no server, token or network, but its text is frozen when scheduled; a remote one is composed by your server at send time and needs a stored token and a connection. On Android both use a channel, and only its name and description can change later.
Test response.ok, true only for statuses 200 through 299, and throw before parsing; otherwise a 500 flows on as data, or response.json() chokes on an HTML error page and the parse error hides the status. Downloads through expo-file-system's File.downloadFileAsync() behave the other way and reject on any non-2xx status.
Model them as one status value instead of several booleans so two can never be true together, and clear loading in a finally so a thrown error cannot leave it set. In a list, ListEmptyComponent renders the empty case, and RefreshControl's refreshing is controlled, so it spins until your code sets it to false.
Every input that changes the response belongs in the query key, or two screens read each other's data, such as a filtered list served from the unfiltered entry. For an optimistic update, snapshot the old value, roll back on error and invalidate when it settles so the server has the last word; a persisted cache lets a cold start show data offline.
Aborting rejects the pending promise, so check controller.signal.aborted in your catch, not the error's name: React Native's fetch rejects with an AbortError, but Expo's, the global since SDK 56, with a FetchError. An abort cannot recall a reply that already arrived, so tag each request and ignore all but the latest.
Give each queued write a client-generated idempotency key, an id the server uses to ignore repeats, since a request that timed out may still have succeeded. Replay in order, stopping at the first failure, and do not trust a connected flag alone: NetInfo reports isInternetReachable apart from isConnected because a Wi-Fi login page blocks traffic.
Expo's background-task module, for one, schedules through BGTaskScheduler on iOS and WorkManager on Android: your interval is only a minimum, at least 15 minutes on Android, the iOS Simulator never runs it, and on iOS a swipe away in the app switcher stops it entirely. Keep anything time-critical on the server, delivered by push.
React Native has no termination event, and iOS can end a suspended app without running any more of your code, so the background transition is your last reliable moment to save. iOS suspends a backgrounded app within seconds, so write drafts and pending changes first and keep that handler short.
Remember the previous state and act only when it was background: iOS passes through inactive for the app switcher, Notification Center and incoming calls, so a handler keyed to any return to active refetches whenever a banner is pulled down. One listener can then refetch, reconnect, check the session and re-read permissions.
React Native stops firing timers on Android once the activity pauses, and iOS suspends the whole app, so a counter decremented by setInterval falls behind by the full absence, and an overdue interval fires once rather than per missed tick. Store the deadline, render deadline - Date.now(), and schedule a notification for anything due meanwhile.
With one client the token refresh can be single-flight: when five requests fail with 401 at once, refresh once and replay all five, instead of racing five refreshes that, under rotation, spend one refresh token five times. The same layer turns network failures, timeouts and bad statuses into one error shape for screens to handle.
fetch has no timeout option, and React Native's Android client sets its connect and read timeouts to zero, meaning none, so add an AbortController deadline. Retry only requests that are safe to repeat, skip 4xx errors other than 408 and 429, and randomize each delay, called jitter, so many phones do not retry in lockstep.
A REST endpoint returns the shape the server chose, so a screen over-fetches or chains round trips, costly on a high-latency cellular link, while a GraphQL query names exactly its fields. A normalized cache stores each object once by type and id, so one edit updates every screen, though POSTing every query to one URL forfeits HTTP caching.
Polling repeats a full request on a timer and wakes the cellular radio each time; SSE is one long HTTP response only the server writes to; a WebSocket carries both directions on one connection. React Native ships WebSocket but no EventSource, and its own fetch cannot stream a body, though Expo's can; none deliver while suspended.
Browsers cannot set headers on a WebSocket, but React Native's constructor takes a non-standard third argument, { headers }, so a token can ride in the handshake rather than a URL that proxies log. A reconnect starts from nothing: authenticate with a fresh token, resubscribe and ask for events since the last id you processed.
U. Expo: modules, config and EAS
Expo Go carries a fixed set of native modules for exactly one SDK version, so native libraries, config plugins, a custom icon or name, universal links and, on Android since SDK 53, remote push all fall outside it. A development build is your app compiled with expo-dev-client: anything native works, but native or config changes need a rebuild.
A plugin is a synchronous function from config to config, and its mods, which edit files such as Info.plist and AndroidManifest.xml, apply only during npx expo prebuild. A change does nothing to an installed binary until you prebuild and rebuild, and a project with hand-kept native folders and no prebuild never applies it.
Under Continuous Native Generation the folders are gitignored, so a hand edit inside them vanishes on the next npx expo prebuild --clean; move it into a config plugin. If you commit the folders instead, EAS Build skips prebuild to avoid overwriting them, and native settings in app config, plugins included, quietly stop applying.
With the default credentialsSource of remote, EAS generates or accepts the Android keystore and the iOS certificate and profile, stores them on Expo's servers and supplies them to each cloud build, so no signing secret sits in your repo. Back them up with eas credentials, or use local with a gitignored credentials.json.
Matching is by exact string, so a native change needs a new value, or the new JavaScript reaches binaries missing the code it calls. The appVersion policy follows your version field and trusts you to bump it; fingerprint hashes everything that affects the native layer, SDK upgrades included, and changes the value for you.
Native means anything compiled into the binary: a library with native code, a config plugin or permission change, the app icon, an SDK upgrade; JavaScript and its assets can ship as an update. expo-updates rolls a device back only when a new bundle crashes before its first render, so a missing module hit later crashes every user who reaches it.
app.json is plain JSON that tooling can rewrite for you, while app.config.js or app.config.ts can read environment variables and receives the static file as config. Both are evaluated on your machine at build or start time, not on the device; values reach the app through extra and Constants.expoConfig, all of it public.
Run npx install-expo-modules@latest once to add the expo package and wire up its autolinking, then add modules with npx expo install. Without prebuild their config plugins never run, so whatever a plugin would have written, such as an Info.plist purpose string or a manifest permission, you add to the native projects by hand.
SDK 56 targets React Native 0.85 and SDK 55 targeted 0.83, so not every React Native release gets an SDK. npx expo install picks package versions matched to your SDK and npx expo install --check reports drift; Expo's packages are written for their SDK's React Native and typically do not support an older one.
The first element is null until the status loads, so render nothing that depends on it before then. The hook reads once on mount and never refreshes, so call its third element, the getter, when the app returns to the foreground; canAskAgain: false, which iOS reports after one denial, means only Settings can grant it.
A build's channel comes from its eas.json profile and points at the same-named branch until you remap it. By default the app checks on every cold launch but runs what it already has, so an update takes effect on the launch after the one that fetched it; eas update:rollback restores an earlier or the embedded update with no store review.
V. Shipping: builds, signing and upgrades
A debug build loads its JavaScript from Metro on your machine and runs with __DEV__ set to true, spending JS-thread time on warnings and checks. Release embeds a minified bundle compiled to Hermes bytecode and hides the dev menu, yet console.log still runs, so time performance in release and strip logs with a Babel plugin.
Under Play App Signing, Google holds the key that signs what users install and your upload key only proves an upload is yours, so a lost or leaked one is replaced by requesting an upload key reset in Play Console. A self-managed signing key has no reset; keep keystore passwords in ~/.gradle/gradle.properties, not the repo.
A development or ad hoc build installs only on devices registered before its provisioning profile was generated, so a new tester means editing the profile and re-signing the app. App Store Connect builds, TestFlight included, need no device list, and registrations cap at 100 per device family per membership year.
A changed identifier is a different app to both stores, so existing users never get it as an update, and on a device it installs beside the old app with none of its data. On Android, keep applicationId explicit in Gradle: when it is absent it falls back to namespace, so a package rename would quietly change it.
Android pairs versionName, a display string, with versionCode, an integer Play never accepts twice and caps at 2100000000. iOS pairs CFBundleShortVersionString with CFBundleVersion, and each upload under one version needs a higher build number. Expo fills them from version, android.versionCode and ios.buildNumber.
With enableProguardInReleaseBuilds on, R8 strips or renames Java and Kotlin classes it never sees referenced, so one reached only through reflection or JNI breaks in release until a -keep rule covers it. Metro's minifier renames JavaScript too: a check on err.constructor.name that passes in debug meets a one-letter name.
The Upgrade Helper lists every change to the generated project files between your exact pair of versions, from Gradle scripts to the Podfile and app delegate, with notes on why each moved. You apply them by hand after updating packages; Expo projects that regenerate native folders delete and rebuild them, one SDK at a time.
A release stack shows minified names and Hermes bytecode offsets until you run it through metro-symbolicate with that build's source map, since a map from another commit points at wrong lines. iOS writes no JS map unless you set SOURCEMAP_FILE, and native frames need the .dSYM whose build UUID matches the crashing binary.
From 0.84, pod install fetches React Native as a precompiled .xcframework by default, after an opt-in in 0.81. That code is never rebuilt locally, so editing React Native's native sources or opting out of Hermes V1 needs RCT_USE_PREBUILT_RNCORE=0 at pod install; a version with no published artifact builds from source.
W. React hooks, as they behave in a React Native app
A screen that returns an ActivityIndicator before its other Hooks calls fewer of them while loading than once data arrives, and React then throws Rendered more hooks than during the previous render. Keep every Hook above the first conditional return, and put the condition inside the Hook's callback instead.
Every setter call in the same handler lands in one render and one commit, so the native views change once rather than stepping through each update. The Object.is bailout is what makes an onScroll handler cheap when it keeps setting the same boolean, while one that stores a fresh { x, y } object re-renders on every event.
Each entry is compared with Object.is, so an object or function built during render counts as changed and re-runs the effect after every commit. A stack keeps the previous screen mounted when you push another, so its cleanup does not run then; work that should pause while the screen is hidden belongs in useFocusEffect.
An Android hardwareBackPress listener added once with an empty dependency array keeps the first render's state, so an unsaved-changes check inside it reads the form as it was on mount. List the value and re-subscribe, or read it through a ref or a useEffectEvent function (added in React 19.2) called from the listener.
A ref reaches a mounted native view's methods, such as focus() on a TextInput or scrollToIndex() on a FlatList, and reads null until that view has mounted. Assigning ref.current schedules nothing, so it suits an interval id or a subscription, but text drawn from a ref stays frozen until something else re-renders.
FlatList is a PureComponent, so an inline renderItem is a new prop each parent render and it re-renders its visible rows; useCallback keeps that identity, and useMemo does the same for a filtered data array. Once renderItem is stable, anything it reads must be in its deps or extraData, or rows show stale state.
Put a scroll offset or a ticking timer in context and every list row that reads it re-renders with each change, starving the JS thread that must also render new rows as you scroll. Keep such values in local state or a store with selectors; an inline value={{ user, setUser }} is also a new object on every provider render.
When fields change together, such as status, data and error for one request, a reducer applies each transition in one place, so a loading flag can never sit beside a stale error. dispatch also keeps one identity for the component's lifetime, so passing it to memoized FlatList rows does not defeat their memoization.
React Native's useWindowDimensions and useColorScheme follow this pattern: a native change subscription wrapped in a use function, with separate state for every caller. Name a Hook-calling helper getInsets and the lint rule reports a Hook called in a function that is neither a component nor a custom Hook.
X. Shared state: what to reach for, and what it costs
Lift a value only as far as the closest parent of every component that reads it, since lifting higher re-renders more of the tree on each change. Navigation params are not a sharing channel either: React Navigation advises passing an id and reading the record from one shared source, because a copied object goes stale.
Theme, locale and the signed-in user fit, since they change a few times per session, while a cart total or a field's text does not. When a context must carry a setter, give the setter its own context so components that only dispatch never re-render, and wrap the provider's object value in useMemo.
Redux Toolkit trades slices and dispatch ceremony for an inspectable, replayable action log; Zustand is a selector-based hook store that needs no provider; Jotai composes small atoms and derived atoms; MobX re-renders observer components from the observables they read, which saves code but cannot see a value read outside them.
A selector ties a component to one value, compared by reference, so a list row reading state.todos[id] stays still while another row's todo is edited, which a single Context cannot offer. A selector that builds a new object on each call defeats this and re-renders on every action unless you compare with shallowEqual.
Keep each record once, in an entities lookup keyed by id beside an ordered ids array, and let other records refer to it by id. A user copied into every post goes stale everywhere except the copy you edit, and a nested update replaces every ancestor object, so rows showing unrelated data re-render too.
A copy of server data needs a freshness policy, deduplicated requests, retries and refetching, which a query library such as TanStack Query supplies. In React Native it cannot see app focus or connectivity by itself: wire focusManager to AppState and onlineManager to NetInfo, or returning to the app triggers no refetch.
A store lives in JS memory, lost when the OS ends a backgrounded app; to keep it, Zustand's persist middleware defaults to localStorage, which React Native lacks, so pass createJSONStorage(() => AsyncStorage). Hydration then lands after the first render, so wait for it before treating a user as signed out.
A modal's open flag or a field's draft in the global store outlives the screen, so reopening it shows stale state, and every keystroke runs every subscriber's selector. A stack can also hold two copies of one screen, which then share that single flag; keep it in useState and lift it only as far as its readers.
Y. Mobile security: what the client can and cannot protect
Everything shipped, from a string in the JS bundle to an EXPO_PUBLIC_ variable inlined at build time, can be read by unpacking the APK or IPA, and every install carries the same copy. A proxy you run, such as a serverless function, holds the key, checks who is calling and rate-limits, so rotating it needs no app update.
React Native's Gradle plugin allows cleartext only in debug, so an http:// endpoint that worked in development fails in a release APK, and iOS refuses plain HTTP beyond local networking. Pinning your server's public key rejects a certificate any CA mis-issued, but ship a backup pin or a key rotation cuts off installed apps.
A quickly expiring access token limits what a leaked copy is worth, while a refresh token in Keychain or Keystore-backed storage renews it silently. With rotation, each refresh retires the previous refresh token, so if a thief and the app both use one, the second use exposes the theft and the server revokes the active token.
A prompt resolving { success: true } in JavaScript is a value a tampered app can fake, so no server should trust it. A secret saved with expo-secure-store's requireAuthentication is released by the OS only after a successful check, and enrolling new biometrics invalidates it, so keep a fallback sign-in.
Async Storage writes unencrypted, so tokens belong in the iOS Keychain or in Keystore-protected storage on Android. Android's FLAG_SECURE blocks screenshots and blanks the recent-apps preview, while iOS snapshots the screen for its app switcher, so cover sensitive views once AppState reports inactive.
Any app or web page can open your link with parameters it chose, and another app can claim the same custom scheme, so treat a deep link as a request to navigate and confirm any action it implies. Request the narrowest grant that works, such as approximate location, since every permission is one more prompt a user can refuse.
Each layer assumes the last one failed: server-side authorization and rate limits hold even after the client is reverse engineered, and attestation such as Play Integrity or App Attest only tells your server a request likely came from your genuine app. Hermes's own hbcdump disassembles bytecode, so compiling hides no key.
Whatever page the view reaches can call the injected window.ReactNativeWebView.postMessage, so check event.nativeEvent.url against your origin and validate data before acting. The default originWhitelist allows any http or https origin, so narrow it and block unexpected loads in onShouldStartLoadWithRequest.
The authorization request sends only code_challenge, the BASE64URL-encoded SHA-256 of a fresh 43-to-128-character code_verifier, which is revealed only at the token request for the server to rehash. An app that hijacked your scheme and caught the code has no verifier, and RFC 8252 requires PKCE of public native clients.
A login page inside your own WebView lets the app read every keystroke and hides the address bar, so RFC 8252 forbids it and Google refuses it with 403 disallowed_useragent. An auth session through ASWebAuthenticationSession or Android Custom Tabs keeps the password outside your process and reuses the browser's sign-in.
Prefer a universal or app link, whose owning app the OS verifies, over a reverse-domain scheme like com.example.app:/oauth2redirect, and register it so the server can match it exactly. Send a random state with each request and refuse any callback whose value matches no pending request, which defeats a forged response.
The ID token is a signed JWT addressed to your app, with your client ID in aud, and its sub identifies the user once you have checked signature, issuer, audience and expiry. The access token is meant for the API and may be opaque to you, so ask the provider's UserInfo endpoint for profile claims rather than decoding it.
Z. Localization and right-to-left layout
Text the OS draws needs the same treatment: a permission prompt shows the usage string from Info.plist, such as NSContactsUsageDescription, and Expo translates it, along with the app name, through per-language files listed under locales in the app config. A lint rule like i18next/no-literal-string catches literals left in components.
Set fallbackLng so a string missing from the Spanish file shows the English one; a regional code such as es-MX already tries es first. When a key is absent in every language, i18next returns the key itself, so checkout.pay appearing on screen is the symptom of a typo or a missing English entry.
Arabic has six plural categories and Japanese one, and i18next picks among suffixes like _one, _few and _other through Intl.PluralRules. The Hermes build that React Native 0.85 ships does not implement it, and i18next relies on it to choose a language's forms, so load an Intl.PluralRules polyfill before i18next initializes.
The same date prints as 3/4/2026 in the US and 04/03/2026 in Britain, and German writes 1.234,50 €, so pass the active locale to Intl.DateTimeFormat and Intl.NumberFormat explicitly. Hermes implements them on each platform's own formatters, so iOS and Android output can differ in small details; check both.
getLocales() from expo-localization lists the device's languages in preference order, so take the first one you have translations for, unless the user picked one in your settings and you saved it. Android lets the user switch languages without restarting your app, so re-read them when AppState returns to active; on iOS they stay fixed while the process runs.
A label under ten characters can reach two or three times its English length in translation, so let buttons and tabs wrap or grow instead of fixing their width. Android's en-XA pseudolocale rewrites only native string resources, so React Native text needs its own accented, padded pseudo-language in your translation files.
Android flips only if the manifest sets android:supportsRtl to true, and iOS only when the device language is one the app declares as supported, so an app shipping no Arabic stays left-to-right on an Arabic phone. Images never mirror on their own: flip a back arrow with transform: [{ scaleX: -1 }] when isRTL is set.
By default React Native rewrites left and right margins, paddings, borders and offsets as start and end in RTL, since doLeftAndRightSwapInRTL starts true, so marginStart mainly documents intent and survives swapLeftAndRightInRTL(false). Nothing outside layout styles mirrors: a translateX or a gesture's dx keeps its physical sign.
AA. Maps and device location
Each getCurrentPositionAsync call waits for a fresh position, which can take seconds indoors; getLastKnownPositionAsync skips the wait but may return a stale position or null. watchPositionAsync updates only in the foreground and resolves to a subscription, so a cleanup that runs before it resolves must call remove() on arrival.
Balanced, the default, targets about 100 meters, and Low or Lowest let the platform avoid power-hungry GPS. Every position reports coords.accuracy, its uncertainty radius in meters, so discard wide readings before drawing a route. In a foreground watch timeInterval is Android-only, leaving iOS just the distanceInterval filter.
Even a granted permission can be approximate, so check android.accuracy for 'coarse' or ios.accuracy for 'reduced' before relying on precision. Since Android 11 the background request opens system settings instead of a dialog, so explain why first, and once canAskAgain is false only the Settings app can reverse it.
coords.heading is the direction you are moving, while watchHeadingAsync reports where the phone points, and its trueHeading reads -1 without location permission. Geofence events go to a task registered with TaskManager.defineTask at a module's top level, and iOS monitors at most 20 regions at once where Android allows 100.
Deltas are spans in degrees: a degree of latitude is about 111 km, and a degree of longitude narrows toward the poles. initialRegion is read only at mount, so changing it later moves nothing, and a fetch belongs in onRegionChangeComplete, which fires once movement ends, not onRegionChange, which fires throughout a drag.
A view nested in Marker replaces the default pin, and tracksViewChanges, true by default, keeps tracking changes to that view at a cost that grows with every marker, so turn it off and call redraw() after a change. A marker's title and description appear only while it has no Callout child.
Leave provider unset and iOS draws Apple Maps while Android draws Google Maps, keyed by the manifest's com.google.android.geo.API_KEY entry, which Expo prebuild writes from the androidGoogleMapsApiKey plugin option. A Google logo and markers over a blank background point at that key, its restrictions, or a disabled Maps SDK.
AB. Typing a React Native app
ReactNode also admits a bare string, so <View>{label}</View> type-checks while React Native reports Text strings must be rendered within a <Text> component in development. A () => void prop passes straight to onPress, but an (id: string) => void one is rejected, since Pressable calls it with a GestureResponderEvent.
StyleProp treats only false, null, undefined and the empty string as falsy, so a numeric count && styles.badge is a type error, as it can yield 0. React.ComponentRef<typeof TextInput> names the instance with focus() and clear() and, unlike useRef<TextInput>, survives the strict TypeScript API, opt-in in 0.85.
NativeStackScreenProps<RootStackParamList, 'Profile'> types that screen's route.params and navigation; an undefined entry means it takes no params. Declare the list with type, since an interface extending ParamListBase lets a misspelled screen name type-check, and a type asserted onto useNavigation() goes unchecked.
With strict checks useState([]) infers never[], so the first setItems([item]) is a type error until you write useState<Item[]>([]). An async effect callback is rejected because it returns a Promise rather than nothing or a cleanup, and React 19's types refuse useReducer<Reducer<S, A>>, so type the reducer's parameters instead.
Separate isLoading and error flags can both be set at once, while loading, error and ready variants make that state unrepresentable and let state.items type-check only inside the ready branch. An action object stored in a variable first widens type to string and stops matching, so annotate it or add satisfies Action.
FlatList infers its item type from data and passes it to renderItem, but useRef<FlatList>(null) defaults the item to any. In a .tsx file write a generic arrow as <T,>, since <T> parses as a JSX tag, and treat a fetch hook's T as your word alone, because res.json() is typed Promise<any>.
JSON.parse returns any, so settings read back from AsyncStorage flow unchecked into every function they reach; type the result unknown and validate its shape. as refuses a string-to-number cast unless you route it through unknown, and even then the value stays a string, so adding 1 to '5' yields '51'.
Since TypeScript 6.0, strict is on unless your tsconfig turns it off, flagging possibly-null refs and untyped parameters, yet an out-of-range scores[10] is still typed number until you add noUncheckedIndexedAccess. keyof typeof styles turns a StyleSheet.create result into the union of its style names.
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
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.