react-native-apps — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited react-native-apps (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Use this as the authoritative guide when building any React Native app. When context is small, just follow the key rules. When the user hits a specific problem, jump to the matching section.
For deep-dive on a specific Expo concern, defer to the official Expo team skills installed alongside this one:
| Concern | Use this specialist |
|---|---|
| Upgrading SDK (54 → 55, dependency hell) | upgrading-expo |
| Building/distributing dev clients | expo-dev-client |
| Build & ship to App Store / Play Store / Web | expo-deployment |
| EAS Workflows YAML / CI-CD | expo-cicd-workflows |
| OTA update health (crash rate, install count) | eas-update-insights |
| Native module authoring (Swift/Kotlin/TS) | expo-module |
| API routes via Expo Router + EAS Hosting | expo-api-routes |
| Universal data fetching, caching, mutations | native-data-fetching |
| UI fundamentals + styling + components | building-native-ui |
| Tailwind v4 + NativeWind v5 setup | expo-tailwind-setup |
@expo/ui Jetpack Compose interop | expo-ui-jetpack-compose |
@expo/ui SwiftUI interop | expo-ui-swift-ui |
| DOM components (run web in webview) | use-dom |
Use this skill (react-native-apps) for stack defaults, architecture, and the 13 gotchas. Use the specialist skills above when the task is narrow and deep.
| Concern | Default pick | Why |
|---|---|---|
| Framework | Expo SDK 54+ with expo-router | File-based routing, typed routes, auth gating, works on iOS/Android/Web |
| Navigation | expo-router route groups (auth)/(tabs)/(onboarding) | Declarative, collocated with screens |
| Language | Strict TypeScript ("strict": true, noUncheckedIndexedAccess) | Mandatory for teams, saves you from silent bugs |
| Client state | Zustand (~1KB, selector-based) | Tiny, fast, no provider, works outside React |
| Server state | TanStack Query v5 | Caching, retries, optimistic updates |
| Storage | react-native-mmkv (sync, 30× faster than AsyncStorage) | Needs dev build. Fallback: AsyncStorage |
| Secrets | expo-secure-store | Keychain/Keystore — never AsyncStorage for tokens |
| API client | fetch + thin wrapper OR axios | Fetch wins for most cases |
| Validation | Zod at API boundaries | Runtime type-safety, infer static types |
| Auth | Clerk or Supabase Auth | Ship fast. Custom JWT only if required |
| Animation | Reanimated 3/4 + Moti (Skia only if canvas needed) | See Section 4 |
| Bottom sheet | @gorhom/bottom-sheet | Settled winner. Built on Reanimated + gesture-handler |
| Charts | react-native-wagmi-charts (simple) / victory-native v40 Skia (complex) | Native perf |
| Icons | @phosphor-icons/react-native or @expo/vector-icons | Tree-shakeable |
| Forms | react-hook-form + zod + react-native-mask-input | Standard combo |
| Errors | Sentry via @sentry/react-native/expo | Uploads sourcemaps on EAS build |
| E2E tests | Maestro (YAML) | Far less pain than Detox |
| Unit tests | Jest + React Native Testing Library + MSW | User-centric, mock network at fetch |
| Build | EAS Build (30 free builds/mo) | No local Xcode/JDK needed |
| CI | GitHub Actions + expo/expo-github-action | Auto-deploy on push |
| OTA | EAS Update (1000 free MAU) | Push JS fixes without rebuilding |
my-app/
├── app/ # expo-router (file-based routing)
│ ├── _layout.tsx # Root providers (QueryClient, ErrorBoundary, ThemeProvider)
│ ├── index.tsx # Landing / redirect based on auth
│ ├── (onboarding)/ # Route group — onboarding flow
│ │ ├── _layout.tsx
│ │ ├── welcome.tsx
│ │ └── disclaimer.tsx
│ ├── (auth)/ # Route group — auth stack
│ │ ├── _layout.tsx
│ │ ├── sign-in.tsx
│ │ └── sign-up.tsx
│ ├── (tabs)/ # Route group — main tab nav
│ │ ├── _layout.tsx
│ │ ├── home.tsx
│ │ ├── bots.tsx
│ │ └── profile.tsx
│ └── +not-found.tsx
├── src/
│ ├── components/
│ │ ├── ui/ # Button, Input, Card (design system)
│ │ └── features/ # Feature-specific composites
│ ├── hooks/ # useAuth, useTheme, useDebounce
│ ├── services/
│ │ ├── api/
│ │ │ ├── client.ts # fetch wrapper
│ │ │ └── endpoints/
│ │ └── queries/ # TanStack Query hooks
│ ├── store/ # Zustand stores
│ ├── lib/ # mmkv, constants, formatters
│ ├── types/
│ └── utils/
├── assets/
├── app.config.ts # typed over app.json
├── eas.json
└── tsconfig.json # strict: true, paths: {"@/*": ["src/*"]}Keep app/ thin — it's routing only. Real logic lives in src/.
`src/lib/storage.ts` — MMKV wrapper usable with Zustand's persist:
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({ id: 'app', encryptionKey: 'rotate-me' });
export const mmkvZustandStorage = {
getItem: (k: string) => storage.getString(k) ?? null,
setItem: (k: string, v: string) => storage.set(k, v),
removeItem: (k: string) => storage.delete(k),
};`src/store/auth.store.ts` — Zustand + persist:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { mmkvZustandStorage } from '@/lib/storage';
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null, token: null,
signIn: (user, token) => set({ user, token }),
signOut: () => set({ user: null, token: null }),
}),
{ name: 'auth', storage: createJSONStorage(() => mmkvZustandStorage) }
)
);`src/services/api/client.ts` — fetch wrapper with auto-logout on 401:
import Constants from 'expo-constants';
import { useAuthStore } from '@/store/auth.store';
const BASE = Constants.expoConfig?.extra?.API_URL as string;
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = useAuthStore.getState().token;
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...init.headers,
},
});
if (res.status === 401) useAuthStore.getState().signOut();
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json();
}`app/_layout.tsx` — providers at root:
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { ErrorBoundary } from 'react-error-boundary';
const qc = new QueryClient({ defaultOptions: { queries: { retry: 2 } } });
export default function RootLayout() {
return (
<ErrorBoundary fallback={<CrashScreen />}>
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<QueryClientProvider client={qc}>
<BottomSheetModalProvider>
<Stack screenOptions={{ headerShown: false }} />
</BottomSheetModalProvider>
</QueryClientProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
</ErrorBoundary>
);
}Environment variables: app.config.ts with extra + expo-constants. Secrets go into EAS Secrets (eas secret:create), never in committed .env. Public client values use EXPO_PUBLIC_* prefix (auto-inlined at build).
Show value BEFORE asking for email. Reduces drop-off 20-40%. Sequence: Splash (1.5s) → 3-4 value slides (react-native-pager-view + Reanimated) → risk disclaimer (if regulated) → optional signup → personalization quiz → main app.
Persist completion in MMKV. Auth-gate via expo-router groups:
<Stack>
{!hasOnboarded ? <Stack.Screen name="(onboarding)" /> :
!isAuthed ? <Stack.Screen name="(auth)" /> :
<Stack.Screen name="(tabs)" />}
</Stack>Telegram/Revolut style. Sits 16-24px above safe-area, rounded-full, dark blur bg, 5 icons max.
<Tabs screenOptions={{
tabBarStyle: {
position: 'absolute', bottom: insets.bottom + 16, marginHorizontal: 24,
borderRadius: 999, height: 64, backgroundColor: 'rgba(20,20,20,0.85)',
borderTopWidth: 0, elevation: 12,
}
}} />Morphing variant (Spotify-style): center tab is FAB that opens a gorhom bottom sheet.
Rule: illustration + one-line empathy + single CTA. NEVER "No data."
Use react-native-svg for crisp illustrations. Centered column, 40% vertical padding.
Animated.View with layout={LinearTransition.springify()}react-native-gesture-handler Swipeablereact-native-deck-swiper or custom PanGestureHandler`@gorhom/bottom-sheet` is the settled winner.
<BottomSheetModal ref={ref} snapPoints={['25%','60%','90%']}
enableDynamicSizing={false}
backdropComponent={BottomSheetBackdrop}>
<BottomSheetView>{/* content */}</BottomSheetView>
</BottomSheetModal>ActivityIndicator inside)Haptics.selectionAsync() // tap nav/button
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) // confirm action
Haptics.notificationAsync(Haptics.NotificationFeedbackStyle.Success)
Haptics.notificationAsync(Haptics.NotificationFeedbackStyle.Error)Wrap every Pressable that triggers state change.
Floating labels. Validate on blur, not on-change (jarring). Submit button disabled until formState.isValid. Errors slide-in via Reanimated FadeInDown.
react-native-wagmi-charts <LineChart> (simple, haptic cursor)victory-native v40+ (Skia-backed) or lightweight-charts via WebViewuseColorScheme + Reanimated shared value, 300ms fade. Interpolate via interpolateColor. Don't remount — causes jank.
const progress = useSharedValue(isDark ? 1 : 0);
useEffect(() => { progress.value = withTiming(isDark ? 1 : 0, {duration:300}); }, [isDark]);
const bg = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(progress.value,[0,1],['#fff','#0a0a0a'])
}));Or drop in react-native-theme-switch-animation for circular reveal.
Drives day-1 retention. Pin a collapsible "Get started" card on home until tasks complete. Each row: circle → checkmark (Reanimated spring), row crosses out. Auto-dismiss at 3/4 done.
Default RefreshControl feels cheap. Custom:
const scrollY = useSharedValue(0);
const onScroll = useAnimatedScrollHandler(e => { scrollY.value = e.contentOffset.y; });
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${interpolate(scrollY.value,[0,-80],[0,360])}deg` }],
opacity: interpolate(scrollY.value,[0,-40],[0,1]),
}));| Library | When |
|---|---|
| Reanimated 3/4 (default) | Anything gesture-driven, 60/120fps, worklets on UI thread. Works in Expo Go. |
| Moti | Declarative wrapper over Reanimated. Great for staggered lists, onboarding, marketing UI. |
Skia (@shopify/react-native-skia) | Custom canvas — particles, shaders, liquid swipe, premium shimmer. Requires dev build. |
| Lottie | Pre-made After Effects animations (success checkmarks, mascots). Works in Expo Go. |
| Animated API | Legacy only. Keep for existing code. |
Press feedback (scale 0.97):
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
// onPressIn: scale.value = withSpring(0.97, { damping: 15, stiffness: 300 });
// onPressOut: scale.value = withSpring(1);Staggered list fade-in (Moti):
<MotiView from={{ opacity: 0, translateY: 20 }} animate={{ opacity: 1, translateY: 0 }}
transition={{ type: 'spring', delay: index * 60 }} />Pulse / breathing:
scale.value = withRepeat(withSequence(
withTiming(1.05, { duration: 800 }),
withTiming(1, { duration: 800 })
), -1, true);Number count-up (TextInput trick):
const value = useSharedValue(0);
useEffect(() => { value.value = withTiming(527.45, { duration: 1200 }); }, []);
const animatedProps = useAnimatedProps(() => ({ text: `$${value.value.toFixed(2)}` }));
// Render with Animated.createAnimatedComponent(TextInput), editable={false}Parallax scroll:
const scrollY = useSharedValue(0);
const onScroll = useAnimatedScrollHandler(e => { scrollY.value = e.contentOffset.y; });
const headerStyle = useAnimatedStyle(() => ({
transform: [{ translateY: scrollY.value * 0.5 }],
opacity: interpolate(scrollY.value, [0, 200], [1, 0]),
}));useNativeDriver: true + only animate opacity/transform. Never width/height/backgroundColor on native driver — silently failsLayout prop OR animate scale + translate instead of real layout for 120HzrunOnJS(cb)() (renamed to scheduleOnRN in Reanimated 4) costs a frame — use sparinglyAnimated.Value mapping — memory cheaper| Library | Expo Go | Dev Build |
|---|---|---|
| Animated API | ✓ | ✓ |
| Reanimated | ✓ | ✓ |
| Moti | ✓ | ✓ |
| Lottie | ✓ | ✓ |
| Skia | ✗ | ✓ |
@gorhom/bottom-sheet | ✓ | ✓ |
react-native-gesture-handler | ✓ | ✓ |
Expo Go ships ONE SDK at a time. Your project SDK must match. Error: "Project is incompatible with this version of Expo Go." Fix: update Expo Go from store, or use expo-dev-client so your build pins its own SDK.
newArchEnabled / edgeToEdgeEnablededgeToEdgeEnabled=false — status/nav bars go transparent.react-native-edge-to-edge + react-native-safe-area-context, read insets via useSafeAreaInsets(), render with SystemBars component.addEventListener returns a subscription now.
// RIGHT — focus-scoped, cleaned up
useFocusEffect(useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
return () => sub.remove();
}, []));Sync (localStorage) vs async (AsyncStorage) — NOT interchangeable. 6MB cap, plaintext. Use expo-secure-store for tokens. On web, @react-native-async-storage/async-storage polyfills over localStorage as async.
useFonts can return [true, null] before fonts finish, producing invisible text or infinite splash.
SplashScreen.preventAutoHideAsync();
const [loaded, error] = useFonts({ Inter: require('./Inter.ttf') });
useEffect(() => { if (loaded || error) SplashScreen.hideAsync(); }, [loaded, error]);
if (!loaded && !error) return null;Always log error. On Android, pre-bundle via expo-font config plugin instead of runtime.
useSafeAreaInsets() silently returns zeros without a provider. Built-in SafeAreaView from react-native is deprecated — use community one.
<SafeAreaProvider><RootNavigator/></SafeAreaProvider>
// screens:
const insets = useSafeAreaInsets();
<View style={{ paddingTop: insets.top }}/>TouchableOpacity wraps children in an extra Animated.View, breaking flex layouts inside flex:1 parents. Prefer `Pressable` in new code.
<Pressable style={({ pressed }) => [styles.btn, pressed && { opacity: 0.6 }]} hitSlop={8} />react-native-workletsbabel.config.js: 'react-native-reanimated/plugin' → 'react-native-worklets/plugin'runOnJS/runOnUI → scheduleOnRN/scheduleOnUIuseWorkletCallback, combineTransition, addWhitelistedNativeProps--clearRequired after editing: babel.config.js, metro.config.js, tsconfig.json paths, SVG transformer, installing/removing native modules. Nuclear: watchman watch-del-all && rm -rf node_modules $TMPDIR/metro-* && npx expo start --clear
r vs rescanr suffices for JS changesapp.json, adding native module, editing metro.config.js, after expo prebuildAndroid 15 + targetSdk 35 breaks it. Use react-native-keyboard-controller's KeyboardAvoidingView in 2025+.
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={headerHeight}
style={{ flex: 1 }} />Set android:windowSoftInputMode="adjustResize" in AndroidManifest.
Nesting same-orientation VirtualizedList in ScrollView kills virtualization and breaks touch on Android.
// WRONG: <ScrollView><FlatList /></ScrollView>
// RIGHT:
<FlatList
data={items}
ListHeaderComponent={<HeroSection/>}
ListFooterComponent={<Footer/>}
removeClippedSubviews
keyExtractor={(i) => i.id}
renderItem={renderItem} // memoized outside component
/>style={[s.box, {mt:10}]} create new refs every render — hoistrenderItem and keyExtractoruseCallback for list row onPress:hermes_enabled in Podfile or OTA bundles fail{
"cli": { "version": ">= 12.0.0", "appVersionSource": "remote" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": { "buildType": "apk" }
},
"preview": {
"distribution": "internal",
"channel": "preview",
"android": { "buildType": "apk" }
},
"production": {
"channel": "production",
"autoIncrement": true,
"android": { "buildType": "app-bundle" }
}
},
"submit": {
"production": {
"android": { "serviceAccountKeyPath": "./google-service-account.json", "track": "internal" }
}
}
}npm install -g eas-cli
eas login
eas build:configure
eas build --platform android --profile preview # APK for sideload
eas build --platform android --profile production # AAB for Play
eas build --local # free, skip queueFree tier (2026): 15 Android + 15 iOS builds/month. Queue 10min–2hr on free. Production plan $199/mo for priority queue.
{
"expo": {
"name": "Your App",
"slug": "your-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "yourapp",
"userInterfaceStyle": "automatic",
"splash": { "image": "./assets/splash.png", "resizeMode": "contain", "backgroundColor": "#000" },
"android": {
"package": "com.yourorg.yourapp",
"versionCode": 1,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#FFD700",
"monochromeImage": "./assets/monochrome-icon.png"
}
},
"ios": { "bundleIdentifier": "com.yourorg.yourapp", "buildNumber": "1" },
"updates": { "url": "https://u.expo.dev/<project-id>" },
"runtimeVersion": { "policy": "appVersion" },
"plugins": [
["expo-build-properties", { "android": { "compileSdkVersion": 35, "targetSdkVersion": 35 } }],
"expo-updates"
],
"extra": { "eas": { "projectId": "..." } }
}
}Gotcha: Package name and bundleIdentifier are immutable once published.
imageWidth max 200EAS auto-manages keystore. On first build it generates one — say yes.
eas credentials # interactive management
eas credentials -p android --profile production # exportSave keystore + password + alias + key password to password manager. Losing them bricks your Play listing forever.
JS-only changes without rebuild:
npx expo install expo-updates
eas update:configure
eas update --branch production --message "Fix button color"
eas channel:edit production --branch productionRuntime version must match binary's. Use "runtimeVersion": {"policy": "appVersion"}. Native module changes still need a new binary.
npx expo install @sentry/react-nativeapp.json:
["@sentry/react-native/expo", { "organization": "your-org", "project": "your-app" }]App.tsx:
import * as Sentry from '@sentry/react-native';
Sentry.init({ dsn: 'https://[email protected]/xxx', tracesSampleRate: 0.2, enableNative: true });
export default Sentry.wrap(App);Store SENTRY_AUTH_TOKEN as EAS secret. Sourcemaps upload automatically.
Must be HTTPS URL, no PDF, no popup.
To avoid policy rejection without broker licensing:
| Reason | Fix |
|---|---|
| Privacy policy missing/broken | HTTPS URL, in-app AND store listing, lists every data type |
| Metadata misleading | Match description to functionality, no keyword stuffing |
| Permissions abuse | Remove unused with blockedPermissions, justify each |
| Crashes during review | Test release build on real low-end Android (API 30, 2GB RAM) |
| Target API too low | Bump to 35 via expo-build-properties |
| Data Safety mismatch | Declare every SDK exactly |
| Financial misrepresentation | Add risk disclaimer, frame educational, contact email |
| Closed testing bypass attempt | Run 12 testers × 14 days — no shortcut on personal accounts |
| Icon violations | 1024×1024 square, no alpha, no rounded corners |
.github/workflows/eas-build.yml:
name: EAS Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- uses: expo/expo-github-action@v8
with: { eas-version: latest, token: ${{ secrets.EXPO_TOKEN }} }
- run: npm ci
- run: eas build --platform android --profile production --non-interactive --no-wait
- run: eas update --branch production --message "${{ github.event.head_commit.message }}"expo-secure-store for all secrets/tokens.Last updated: 2026-04.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.