>_devkit
react-native-expo
skills/react-native-expo/

references/quality.md

Performance, testing, accessibility, security

Performance

Since Expo SDK 55 the New Architecture is the only architecture and the legacy bridge is gone, which retires most pre-2025 advice. What still matters, in order of payoff:

Lists

FlashList v2 for anything longer than a screen. v2 measures automatically, so estimatedItemSize, estimatedListSize, and estimatedFirstItemOffset are gone; delete them when migrating, and note the ref type is now FlashListRef.

  • keyExtractor returns a stable id, never the index
  • getItemType when rows have different shapes, so recycling works
  • The row component is defined outside the list, not inline
  • No inline arrow props on rows if you can pass the id and let the row call back
  • Nothing heavy in renderItem: no date parsing, no .filter() over another

array, no new Intl.NumberFormat per row. Precompute, or memoise the formatter at module scope.

A list that is still janky after this is usually a row that renders an unsized remote image, or a shadow on every row.

Animation

Reanimated worklets, on the UI thread. The failure mode is driving animation from React state in a scroll or gesture handler, which puts every frame through the JS thread. useAnimatedScrollHandler and shared values instead.

react-native-gesture-handler for touch. LayoutAnimation is legacy; use Reanimated layout animations.

Memoisation

React Compiler is enabled by default in Expo SDK 54 and later. Adding useMemo, useCallback, and React.memo out of habit now costs readability and buys nothing. Write plain components. Reach for manual memoisation only when a profile shows a specific problem, and leave a comment saying what it showed.

The compiler cannot save you from the two things that actually cause re-render storms: a Zustand hook called without a selector, and context holding a value that changes often.

Everything else

  • expo-image for remote and cached images, with an explicit width and height.

Unsized images cause layout passes on every load.

  • Lazy-load heavy screens and heavy libraries. A chart library or a PDF viewer

imported at the top of a tab layout is in your startup path.

  • Keep the root layout thin. Font loading, splash screen control, and provider

setup only.

  • Hermes bytecode diffing in SDK 55 makes OTA updates roughly a quarter of their

previous size, which changes the calculus on shipping small fixes often.

  • Measure on a low-end Android device. Every performance decision made on an

iPhone Pro is a guess.

Testing

Three layers, and the middle one carries the weight:

LayerToolCovers
UnitJestschemas, formatters, reducers, pure feature logic
ComponentReact Native Testing Librarywhat a user sees and does on a screen
JourneyMaestrosign in, checkout, the two or three flows that pay the bills

Component tests are the ones worth writing

Test behaviour through the accessible surface, not implementation:

const { getByRole, findByText } = render(<InvoiceListScreen />, { wrapper: TestProviders });
fireEvent.press(getByRole('button', { name: 'Create invoice' }));
expect(await findByText('New invoice')).toBeVisible();

Rules:

  • Query by role and accessible name first, testID only when there is no

accessible way to reach the element. A test that can only find an element by testID is telling you the element is invisible to screen readers too.

  • Mock at the network boundary, with MSW or a fetch mock. Never mock your

own hooks or stores; a test that mocks useInvoices asserts that your mock returns what you told it to.

  • One TestProviders wrapper in lib/test-utils.tsx with a fresh

QueryClient per test (retry: false), theme, and i18n. A shared client leaks cache between tests and produces order-dependent failures.

  • Reset Zustand stores between tests. They are module singletons and they

will remember the previous test's session.

  • Freeze time and seed randomness. Relative timestamps are the most common

cause of a suite that fails only after midnight.

Atoms and molecules are cheap to test because they take props and nothing else, which is a useful signal: if a component in ui/ needs providers mounted to test, it is not really shared UI.

E2E

Maestro, because the YAML flows sit outside the codebase, survive refactors, and do not need a native build step to author. Keep the suite small and about money: sign in, the core create flow, checkout, sign out. An E2E suite that tries to cover everything becomes a suite nobody trusts and everybody reruns.

Run component tests on every push, E2E on merge to main and before release.

Accessibility

Not optional, and in several jurisdictions it is contractual. React Native gives you nothing by default: an unlabelled Pressable is invisible to a screen reader.

Every interactive element needs:

<Pressable
  accessibilityRole="button"
  accessibilityLabel="Pay invoice"
  accessibilityState={{ disabled: isSubmitting, busy: isSubmitting }}
  hitSlop={8}
/>
  • Bake it into the atoms. Button sets accessibilityRole="button" and

derives the label from its title. Then the app is accessible by default instead of by discipline.

  • Icon-only controls always need an explicit label. This is where the bugs

are, and it is another reason emoji are unacceptable as icons: the screen reader announces the emoji's Unicode name, not the action.

  • Headings. accessibilityRole="header" on screen titles gives screen

reader users navigation.

  • Do not disable allowFontScaling. Test at 200% type size.
  • Contrast 4.5:1 for body text. Check it when the token file is written, not

after the client's accessibility audit.

  • Respect reduce-motion (AccessibilityInfo.isReduceMotionEnabled) in any

animation longer than a transition.

  • testID conventions: <screen>-<element>-<role>, stable, never

localised. Keep them distinct from accessibility labels.

Security

  • Nothing secret ships in the bundle. EXPO_PUBLIC_ values are inlined and

readable by anyone who downloads the app. No API secrets, no signing keys, no private endpoints that rely on obscurity.

  • Tokens go to expo-secure-store. Access tokens short-lived, refresh

tokens in secure storage, refresh handled by a single interceptor with request queuing so a burst of 401s does not fire five refreshes.

  • MMKV and AsyncStorage are not secure storage. MMKV supports encryption and

is fine for cache and preferences; it is not where credentials or PII go.

  • Sign-out clears everything: secure store, every MMKV instance,

queryClient.clear(), and a server-side revoke. If the revoke call fails, still clear locally.

  • Certificate pinning for anything financial or medical, via

expo-build-properties.

  • Biometrics unlock a stored credential, they do not replace authentication.
  • Deep links are untrusted input. Validate every route param with zod before

it reaches a query or a mutation.

  • Scrub PII from telemetry. Sentry's beforeSend strips emails, tokens, and

request bodies. Turn off automatic breadcrumb capture of network payloads.

  • Screenshot protection on screens showing sensitive data, and

secureTextEntry on anything credential-shaped.

Errors and observability

Three layers, and skipping the middle one is how you get white-screen reports with no stack:

  1. Root error boundary in app/_layout.tsx. Catches anything unhandled,

shows a recoverable screen, reports to Sentry.

  1. Per-screen boundaries around independently failing regions. A crashing

recommendations widget should not take down the cart.

  1. Query and mutation errors handled in the UI as states, not as thrown

exceptions. isError renders a retry, it does not hit a boundary.

Sentry setup that is actually useful: source maps uploaded in the EAS build so stack traces are readable, release and dist tagged to match the build, the EXPO_PUBLIC_ENV set as the environment so staging noise is separable, and user id (not email) attached after sign-in.

Log breadcrumbs at feature boundaries, not everywhere. A hundred breadcrumbs per session is the same as none.