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.
keyExtractorreturns a stable id, never the indexgetItemTypewhen 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-imagefor 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:
| Layer | Tool | Covers |
|---|---|---|
| Unit | Jest | schemas, formatters, reducers, pure feature logic |
| Component | React Native Testing Library | what a user sees and does on a screen |
| Journey | Maestro | sign 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,
testIDonly 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
TestProviderswrapper inlib/test-utils.tsxwith 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.
ButtonsetsaccessibilityRole="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.
testIDconventions:<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
beforeSendstrips 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:
- Root error boundary in
app/_layout.tsx. Catches anything unhandled,
shows a recoverable screen, reports to Sentry.
- Per-screen boundaries around independently failing regions. A crashing
recommendations widget should not take down the cart.
- 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.