DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for React Native: Production Rules for AI-Assisted Mobile Development

Cursor Rules for React Native: The Complete Guide to AI-Assisted Mobile Development

React Native is the framework where "the JS bundle loaded" hides the longest lie. The app opens, the bridge ferries a hundred messages per second, and nothing in the Metro logs tells you that the 2,000-item FlatList is freezing the thread every time you tap a row, that the useEffect in the header re-subscribes to the redux store on every parent render, that the Animated.timing is driving layout from JS and missing every second frame under load, or that the "cross-platform" card component silently positions itself 20pt below the status bar on Android because the safe-area inset math is iOS-only. The app ships to Testflight. Three days later the mid-range Android QA device reports 40-fps scrolling and a keyboard that covers the submit button.

Then you add an AI assistant.

Cursor and Claude Code were trained on a planet's worth of React Native — most of it pre-0.70, old-architecture, ScrollView-inside-ScrollView, StyleSheet.create merged with inline objects, Platform.OS === 'web' branches for React Native Web attempts that were abandoned. Ask for "a list screen that loads users and lets you search," and you get a FlatList without keyExtractor, no getItemLayout, an inline renderItem that allocates a new function on every render, a TextInput with no keyboardAppearance, and a SafeAreaView that doesn't know about the iPhone 15's dynamic island. It runs. It's not the React Native you would ship in 2026.

The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern React Native looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.


How Cursor Rules Work for React Native Projects

Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended for anything bigger than a single-app repo). For React Native I recommend modular rules so an Expo-managed app's build conventions don't bleed into a bare-workflow app's native-module constraints:

.cursor/rules/
  rn-core.mdc            # TypeScript, components, hooks
  rn-navigation.mdc      # React Navigation typed routes
  rn-state.mdc           # Zustand / Redux Toolkit / Tanstack Query
  rn-perf.mdc            # FlashList, Reanimated, memoization
  rn-platform.mdc        # iOS/Android specifics, safe area, keyboard
  rn-testing.mdc         # RTL-native, Detox, mocks
Enter fullscreen mode Exit fullscreen mode

Frontmatter controls activation: globs: ["**/*.ts", "**/*.tsx"] with alwaysApply: false. Now the rules.


Rule 1: TypeScript Strict Mode, Typed Navigation, No any Through Props

The most common AI failure in React Native is an any-shaped navigation.navigate('Screen', params) call. Cursor generates routes as strings and params as inline objects, so navigation.navigate('UserDetails', { id: 42 }) compiles against a screen that expects { userId: string } and blows up at runtime with "cannot read property userId of undefined." Same story with props — a Card that accepts any and does props.user.name.toUpperCase() crashes when you forget to pass user.

The rule:

tsconfig.json: strict: true (includes noImplicitAny, strictNullChecks),
  noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true,
  noImplicitOverride: true, noFallthroughCasesInSwitch: true.

No `any`. Unknown external data typed `unknown` and narrowed with Zod
or type predicates. `as` casts only at narrow boundaries (just-parsed
JSON), never in handler code.

React Navigation routes are typed:
  type RootStackParamList = {
    Home: undefined;
    UserDetails: { userId: string };
    Editor: { postId?: string };
  };
  type HomeProps = NativeStackScreenProps<RootStackParamList, 'Home'>;

Screen components accept the typed props; useNavigation is typed via
  const nav = useNavigation<NativeStackNavigationProp<RootStackParamList>>();

All components have explicit prop interfaces. No destructuring anonymous
objects as props. Discriminated unions for variant components (a Button
is `PrimaryButtonProps | SecondaryButtonProps | DestructiveButtonProps`).

Zod schemas at every external boundary (API responses, deep-link params,
notification payloads). Parse with `.parse()` in dev, `.safeParse()`
in production with a typed error path.
Enter fullscreen mode Exit fullscreen mode

Before — string-typed routes, untyped props, runtime crash waiting:

export function HomeScreen({ navigation }: any) {
  return <Button onPress={() => navigation.navigate('UserDetails', { id: 42 })} />;
}

export function UserDetailsScreen({ route }: any) {
  return <Text>{route.params.userId.toUpperCase()}</Text>; // crashes: id, not userId
}
Enter fullscreen mode Exit fullscreen mode

After — typed stack, narrowed params, no any:

export type RootStackParamList = {
  Home: undefined;
  UserDetails: { userId: string };
};

type HomeProps = NativeStackScreenProps<RootStackParamList, 'Home'>;

export function HomeScreen({ navigation }: HomeProps) {
  return (
    <Button
      title="Open"
      onPress={() => navigation.navigate('UserDetails', { userId: '42' })}
      //                                      ^ { userId: string } — compile error for { id: 42 }
    />
  );
}

type UserDetailsProps = NativeStackScreenProps<RootStackParamList, 'UserDetails'>;

export function UserDetailsScreen({ route }: UserDetailsProps) {
  const { userId } = route.params; // typed string
  return <Text>{userId.toUpperCase()}</Text>;
}
Enter fullscreen mode Exit fullscreen mode

Renaming a param is caught by the compiler everywhere it's used.


Rule 2: FlashList Over FlatList for Anything That Scrolls Past a Screen

FlatList is RN's default list. On a mid-range Android phone it chokes around 200 items. Shopify's FlashList (from @shopify/flash-list) is a drop-in replacement with recycler-style view recycling — it uses 4x less memory and scrolls at 60 fps on the cheapest phone in your support matrix. Cursor still generates FlatList by default, without keyExtractor, with an inline renderItem, and without getItemLayout.

The rule:

Every scrolling list uses @shopify/flash-list's `FlashList`.
`FlatList` is banned except for lists known to be <20 items.

Required props on every FlashList:
  data
  renderItem        hoisted, memoized with useCallback, or top-level function
  keyExtractor      stable, string, never the index for mutable lists
  estimatedItemSize  required; measure with `recordInteraction` in dev

Prohibited patterns inside FlashList:
  - inline arrow functions for renderItem without useCallback
  - new style objects created in renderItem (hoist to StyleSheet)
  - nested scrolling components without FlashList's `onEndReached` pattern
  - ScrollView parent wrapping a list (breaks recycling)

Separate item component with React.memo:
  const UserRow = React.memo<UserRowProps>(function UserRow({ user, onPress }) {...});
  Props are primitives or stable references (useCallback on onPress).

Pagination via TanStack Query's `useInfiniteQuery` hooked into
`onEndReached`, not manual state flags.

Long images inside list items use FastImage or expo-image with
explicit `style.width` / `style.height`, source.priority = 'low',
cache.memory policy.
Enter fullscreen mode Exit fullscreen mode

Before — FlatList, inline renderItem, no keyExtractor, new style object per row:

<FlatList
  data={users}
  renderItem={({ item }) => (
    <View style={{ padding: 16, borderBottomWidth: 1 }}>
      <Text>{item.name}</Text>
    </View>
  )}
/>
Enter fullscreen mode Exit fullscreen mode

300 users = 300 View and Text allocations on every scroll. keyExtractor defaults to index — deleting an item moves state across rows.

After — FlashList, memoized row, hoisted styles:

const styles = StyleSheet.create({
  row: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#eee' },
});

const UserRow = React.memo<{ user: User; onPress: (id: string) => void }>(function UserRow({
  user,
  onPress,
}) {
  const handle = useCallback(() => onPress(user.id), [onPress, user.id]);
  return (
    <Pressable style={styles.row} onPress={handle}>
      <Text>{user.name}</Text>
    </Pressable>
  );
});

export function UserList({ users, onOpen }: Props) {
  const keyExtractor = useCallback((u: User) => u.id, []);
  const renderItem = useCallback(
    ({ item }: { item: User }) => <UserRow user={item} onPress={onOpen} />,
    [onOpen]
  );

  return (
    <FlashList
      data={users}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      estimatedItemSize={56}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Smooth scroll on a Pixel 4a. Rows recycle. Deleting an item doesn't shift state.


Rule 3: Animations Belong on the UI Thread — Reanimated Worklets, Not JS Animated

The old Animated API runs the animation driver on the JS thread unless you opt into useNativeDriver: true, which works for transform/opacity only. Anything layout-driven (width, height, padding) is stuck on JS and misses frames whenever the JS thread is busy — which it always is under load. Reanimated 3 runs animations as worklets directly on the UI thread, giving you 120fps animations that don't care what JS is doing.

The rule:

All new animations use react-native-reanimated (v3+). The legacy
`Animated` API from react-native core is banned for new code.

Shared values via `useSharedValue` hold the animated state.
`useAnimatedStyle` returns the style object read on the UI thread.
Transitions driven by `withTiming`, `withSpring`, `withSequence`,
`withRepeat`  all UI-thread.

Worklets are pure. No component state reads inside a worklet.
Use `runOnJS(setState)(value)` explicitly to hop back to JS.

Gestures via react-native-gesture-handler (`GestureDetector`,
`Gesture.Pan()`, `Gesture.Tap()`)  not the legacy `PanResponder`.

Layout animations (mounting, unmounting, reordering) use
`entering={FadeIn.duration(200)}` / `exiting={FadeOut}`  not manual
Animated sequences.

Do NOT animate layout props (`width`, `height`, `padding`, `flex`)
unless you must; prefer `transform: [{ scale }, { translateY }]` and
`opacity`. Layout animations trigger shadow-tree reflows.

`useNativeDriver: true` everywhere legacy Animated is still needed.
Never animate `backgroundColor` or `color` via the JS driver.
Enter fullscreen mode Exit fullscreen mode

Before — JS-driven Animated, PanResponder, layout-animated width:

const width = useRef(new Animated.Value(100)).current;

Animated.timing(width, { toValue: 300, duration: 300 }).start();

return <Animated.View style={{ width, height: 50, backgroundColor: 'blue' }} />;
Enter fullscreen mode Exit fullscreen mode

Locks frames whenever the JS thread is busy. width triggers a reflow each step.

After — Reanimated worklet, transform-based scale:

const scale = useSharedValue(1);

const animatedStyle = useAnimatedStyle(() => ({
  transform: [{ scale: scale.value }],
}));

useEffect(() => {
  scale.value = withSpring(3, { damping: 12, stiffness: 120 });
}, [scale]);

return (
  <Animated.View
    style={[{ width: 100, height: 50, backgroundColor: 'blue' }, animatedStyle]}
  />
);
Enter fullscreen mode Exit fullscreen mode

Runs on the UI thread. Identical visual intent without touching layout.


Rule 4: Hooks Discipline — Stable Dependencies, No Re-subscribe Loops

Every RN codebase I've reviewed has the same bug: an effect that subscribes to an event, lists navigation in its deps, and re-subscribes on every render because the navigation object is a fresh reference. Cursor reproduces this pattern faithfully. Layer on top: inline objects in useMemo deps, useEffect with empty deps that lie about dependencies, and useCallback wrapped around handlers whose deps include props itself.

The rule:

Every useEffect / useMemo / useCallback has an exact, minimal dependency
array. Lint `react-hooks/exhaustive-deps` at error level.

Objects and functions from React Navigation (`navigation`, `route`) are
stable  include them in deps; their identity is stable across renders.

Values from render-scope (props, state, derived locals) go in deps.
To avoid a stale closure without a re-subscribe, use the ref pattern:
  const cbRef = useRef(cb);
  useLayoutEffect(() => { cbRef.current = cb; });
  useEffect(() => subscribe(() => cbRef.current()), [subscribe]);

Never use `useMemo` for primitives. `useMemo` is a performance hint,
not a correctness tool  only use it for expensive computations or
stable references passed to memoized children.

Never use `useCallback` for handlers not passed to memoized children
or effect dep arrays. It has a cost.

Custom hooks named `useFoo` respect the rules of hooks and return the
same shape every call. No conditional `return null` from a hook.

State updaters use the callback form when the new value depends on
the old (`setCount(c => c + 1)`).

Prefer `useReducer` over multiple correlated `useState`s (wizard flows,
form state, anything with transitions).
Enter fullscreen mode Exit fullscreen mode

Before — re-subscribe loop, lying deps, stale closure:

function Screen({ onItem }: Props) {
  useEffect(() => {
    const sub = DeviceEventEmitter.addListener('scan', (item) => onItem(item));
    return () => sub.remove();
  }, []); // lies — depends on onItem
}
Enter fullscreen mode Exit fullscreen mode

Either stale closure (listens with the first render's onItem) or if you add onItem to deps, it re-subscribes on every parent render.

After — ref-based stable callback, truthful deps:

function Screen({ onItem }: Props) {
  const cbRef = useRef(onItem);
  useLayoutEffect(() => {
    cbRef.current = onItem;
  });

  useEffect(() => {
    const sub = DeviceEventEmitter.addListener('scan', (item: ScanEvent) =>
      cbRef.current(item)
    );
    return () => sub.remove();
  }, []); // stable — subscribes once
}
Enter fullscreen mode Exit fullscreen mode

Always calls the latest onItem. Subscribes once.


Rule 5: Server State in TanStack Query — Not useEffect + useState

The useEffect(() => { fetch(...).then(setData); }, []) pattern is everywhere in AI-generated RN. It has no caching, no retry, no background refresh, no pagination primitive, no dedupe, and no consistent loading/error shape. TanStack Query (@tanstack/react-query) solves every one of those with twenty lines of setup, and its mobile story (offline persistence, focus refetch on AppState, pull-to-refresh integration) is particularly strong.

The rule:

Server state is fetched and cached by `@tanstack/react-query`.
`useState` + `useEffect` + `fetch` is banned for server data.

One `QueryClient` at the app root inside `QueryClientProvider`.
  staleTime: 30_000                  # fresh for 30s
  gcTime: 5 * 60_000                 # garbage-collect after 5min
  retry: (count, err) => count < 3 && !is4xx(err)
  refetchOnReconnect: true
  refetchOnWindowFocus: false        # web concept; reimpl with AppState

Query keys are typed, hierarchical, and from a central `queryKeys`
factory:
  queryKeys.users.list({ q })   -> ['users', 'list', { q }]
  queryKeys.users.detail(id)    -> ['users', 'detail', id]
Never inline string arrays scattered across files.

Mutations via `useMutation` with optimistic updates for UX-critical
paths. On success, invalidate the affected query keys, not the world.

Offline: `onlineManager.setEventListener` wired to NetInfo.
Persist the cache with `@tanstack/react-query-persist-client` +
AsyncStorage (mobile) or MMKV for speed.

Infinite scroll: `useInfiniteQuery` with `getNextPageParam`; FlashList's
`onEndReached` fires `fetchNextPage`.

Never call `queryClient.getQueryData` from render; use `useQuery` with
`initialData` or `placeholderData` for seeded lists.
Enter fullscreen mode Exit fullscreen mode

Before — useEffect fetch, no cache, no retry, shared loading bug:

function UsersScreen() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/users')
      .then((r) => r.json())
      .then((u) => setUsers(u))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <ActivityIndicator />;
  return <UserList users={users} />;
}
Enter fullscreen mode Exit fullscreen mode

Returns to the screen — refetches every time. Network blip — permanent error state, no retry. Stale data sits forever.

After — TanStack Query, typed keys, proper cache:

export const queryKeys = {
  users: {
    all: ['users'] as const,
    list: (filters: UserFilters) => [...queryKeys.users.all, 'list', filters] as const,
    detail: (id: string) => [...queryKeys.users.all, 'detail', id] as const,
  },
};

export function useUsers(filters: UserFilters) {
  return useQuery({
    queryKey: queryKeys.users.list(filters),
    queryFn: ({ signal }) => api.users.list(filters, { signal }),
    staleTime: 60_000,
  });
}

function UsersScreen() {
  const { data, isPending, isError, refetch, isRefetching } = useUsers({ q: '' });

  if (isPending) return <ActivityIndicator />;
  if (isError) return <ErrorView onRetry={refetch} />;
  return (
    <UserList users={data} refreshing={isRefetching} onRefresh={refetch} />
  );
}
Enter fullscreen mode Exit fullscreen mode

Cached between screens, retried on network flap, pull-to-refresh wired for free.


Rule 6: Platform, Safe Area, and Keyboard — Stop Hardcoding Device Math

Cursor loves hardcoding. paddingTop: 44 (iPhone status bar, wrong on Android and on iPhones with dynamic islands), paddingBottom: 34 (iPhone home indicator, wrong on Android), KeyboardAvoidingView with no behavior prop (broken on Android). The result is a form that renders fine on the simulator and covers the submit button on half the real devices.

The rule:

Safe-area insets come from react-native-safe-area-context:
  `SafeAreaProvider` wraps the app root.
  `useSafeAreaInsets()` in screens that need raw numbers.
  `SafeAreaView` from this package  NEVER the core one (it's deprecated
  and doesn't understand dynamic-island devices).

Pad explicitly by intent:
  paddingTop: insets.top, paddingBottom: insets.bottom
No hardcoded numbers (34, 44, 20, 88, 56) for status bar / home
indicator / tab bar.

Keyboard handling:
  KeyboardAvoidingView with `behavior={Platform.OS === 'ios' ? 'padding' : 'height'}`
  (or use react-native-keyboard-controller / KeyboardAwareScrollView).
  Every TextInput sets `returnKeyType`, `autoCapitalize`, `autoCorrect`,
  `textContentType`, `keyboardType`, `autoComplete`.

Platform differences via Platform.select, not nested ternaries:
  const padding = Platform.select({ ios: 16, android: 12, default: 14 });

Never:
  - `Platform.OS === 'web'` unless you genuinely ship React Native Web
  - string concatenation for file paths (use `react-native-fs` helpers
    or expo-file-system)
  - `Dimensions.get('window')` at module top-level (captures at bundle
    load, doesn't update on rotation — use `useWindowDimensions`)

Respect user settings:
  `useColorScheme()` for light/dark. Never hardcode `Appearance.getColorScheme()`
  at top-level.
  Dynamic type (iOS) / font scale (Android) via `Text.allowFontScaling`
  and `PixelRatio.getFontScale()`.
Enter fullscreen mode Exit fullscreen mode

Before — hardcoded insets, broken Android keyboard, no text-input hints:

export function SignupScreen() {
  return (
    <View style={{ flex: 1, paddingTop: 44, paddingBottom: 34 }}>
      <KeyboardAvoidingView>
        <TextInput placeholder="email" />
        <TextInput placeholder="password" secureTextEntry />
        <Button title="Sign up" />
      </KeyboardAvoidingView>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

After — safe-area-aware, platform-correct, a11y-hinted:

export function SignupScreen() {
  const insets = useSafeAreaInsets();
  return (
    <View style={{ flex: 1, paddingTop: insets.top, paddingBottom: insets.bottom }}>
      <KeyboardAvoidingView
        style={{ flex: 1 }}
        behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      >
        <TextInput
          placeholder="email"
          keyboardType="email-address"
          autoCapitalize="none"
          autoCorrect={false}
          textContentType="emailAddress"
          autoComplete="email"
          returnKeyType="next"
        />
        <TextInput
          placeholder="password"
          secureTextEntry
          textContentType="password"
          autoComplete="current-password"
          returnKeyType="done"
        />
        <Button title="Sign up" onPress={submit} />
      </KeyboardAvoidingView>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Renders right on iPhone 15 Pro, Pixel 6a, and older devices. Keyboard pops suggestions. Password manager fills both fields.


Rule 7: Error Boundaries and Crash Reporting — Not try/catch Spaghetti

Cursor's error story is a try/catch around every async call with console.error. In production that drops errors silently — nobody sees them, nobody fixes them, the app looks fine until it doesn't. Modern RN apps have a tiered error strategy: ErrorBoundary at screen boundaries, Sentry (or Bugsnag / Rollbar) at the app level, typed recoverable errors for expected failures, and a global JS-error handler for the truly unexpected.

The rule:

Crash reporter (Sentry / Bugsnag) installed at the earliest point of
the JS bundle  inside `index.js` before anything else imports.
Native crash integration enabled (sentry-react-native wraps iOS/Android
crashes too).

`ErrorBoundary` (from react-error-boundary) at every screen root.
Fallback renders a retry UI, not a blank screen:
  <ErrorBoundary fallbackRender={ErrorFallback} onReset={reset}>
    <Screen />
  </ErrorBoundary>

`global.ErrorUtils.setGlobalHandler` for unhandled JS errors; also
hook into `onunhandledrejection` for promise rejections that escape.

Expected errors are TYPED:
  class NotFoundError extends AppError {}
  class NetworkError extends AppError {}
  ...
Handlers switch on class, not on error message strings.

Never `catch (e) { console.log(e); }` without a recovery path. Either:
  - Surface to the user (toast, snackbar, inline message)
  - Recover silently WITH a reporter.captureMessage call
  - Re-throw so the boundary handles it
  - Handle a SPECIFIC typed error and rethrow the rest

Network errors include request context (URL, method, status, request id)
in the report. Never just `String(err)`.

Sentry breadcrumbs from navigation events: attach a navigation listener
that `Sentry.addBreadcrumb({ category: 'navigation', ... })`.
Enter fullscreen mode Exit fullscreen mode

Before — catch-and-console.log, silent failure, no boundary:

async function submit() {
  try {
    await api.save(form);
    nav.goBack();
  } catch (e) {
    console.log(e);
  }
}
Enter fullscreen mode Exit fullscreen mode

After — typed errors, toast on recoverable, reporter on unexpected, boundary around screen:

async function submit() {
  try {
    await api.save(form);
    nav.goBack();
  } catch (err) {
    if (err instanceof ValidationError) {
      toast.error(err.message); // recoverable — user can fix
      return;
    }
    if (err instanceof NetworkError) {
      toast.error('No connection. Check your network and try again.');
      Sentry.captureException(err, { tags: { kind: 'network' } });
      return;
    }
    Sentry.captureException(err); // unexpected — report and throw to boundary
    throw err;
  }
}

// Screen mount:
export function SettingsScreen() {
  return (
    <ErrorBoundary fallbackRender={ScreenErrorFallback}>
      <Settings />
    </ErrorBoundary>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every error has a path: shown to user, reported, or both.


Rule 8: Testing — React Native Testing Library for Units, Detox for E2E

Cursor's default RN test is renderer.create(<MyScreen />).toJSON() — a snapshot that locks in every render implementation detail. Modern RN testing is two layers: React Native Testing Library (@testing-library/react-native) for component/hook units (assertions on user-observable output, not internals), and Detox for gray-box end-to-end tests against real simulators/emulators (real touches, real navigation, real Metro bundle).

The rule:

Unit / integration tests: @testing-library/react-native.
  - `render(<Screen />)` wraps in all the real providers (Query, SafeArea,
    Navigation mock, Gesture Handler) via a shared `renderWithProviders`.
  - Find by accessible role, label, or testID  in that order.
    `getByRole('button', { name: 'Sign up' })` preferred over `getByTestId`.
  - Every interactive element has an accessibility label; rendering the
    UI exposes what assistive tech sees.
  - Assert on user-visible output (text, a11y state). Never assert on
    component state or props directly.
  - `userEvent.press`, `userEvent.type` over `fireEvent` when the library
    supports it  matches real gestures.
  - MSW (`msw/native`) for HTTP stubbing at the network boundary.
  - Never snapshot a full screen. Snapshots on tiny presentational
    components only, with descriptive names.

E2E: Detox with detox-init and a dedicated `e2e/` directory.
  - `launchApp({ newInstance: true })` between specs.
  - Interact via `element(by.id('submit'))` (stable) or `by.label`.
  - Assert via `toBeVisible`, `toHaveText`. Never `sleep(ms)`  use
    `waitFor(...).withTimeout(...).toBeVisible()`.
  - Mock network at the native layer if you must, but preferred is
    running against a staging backend with deterministic seed data.

Hook tests: `renderHook` from @testing-library/react-native with
`wrapper={AllTheProviders}`.

Coverage: aggregate >80%; enforce per-package floors for feature
modules. Mutation tests (stryker-mutator) on critical business-logic
modules.
Enter fullscreen mode Exit fullscreen mode

Before — snapshot of render tree, no real interaction, brittle:

test('renders', () => {
  const tree = renderer.create(<SignupScreen />).toJSON();
  expect(tree).toMatchSnapshot();
});
Enter fullscreen mode Exit fullscreen mode

Passes until someone changes a margin. Catches no behavior.

After — render with providers, assert on user-visible output, real HTTP stub:

const server = setupServer(
  http.post('https://api.example.com/signup', async ({ request }) => {
    const body = (await request.json()) as { email: string; password: string };
    if (!body.email.includes('@')) return HttpResponse.json({ error: 'email' }, { status: 422 });
    return HttpResponse.json({ id: 'u_1' });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('submitting a valid signup navigates to the home screen', async () => {
  const navigate = jest.fn();
  render(<SignupScreen navigation={{ navigate } as never} />, {
    wrapper: AllTheProviders,
  });

  await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'ada@ex.com');
  await userEvent.type(screen.getByLabelText(/password/i), 'correct horse battery staple');
  await userEvent.press(screen.getByRole('button', { name: /sign up/i }));

  await waitFor(() => {
    expect(navigate).toHaveBeenCalledWith('Home', undefined);
  });
});

test('surfacing a 422 shows an inline email error', async () => {
  render(<SignupScreen />, { wrapper: AllTheProviders });

  await userEvent.type(screen.getByLabelText(/email/i), 'invalid');
  await userEvent.type(screen.getByLabelText(/password/i), 'x'.repeat(12));
  await userEvent.press(screen.getByRole('button', { name: /sign up/i }));

  expect(await screen.findByText(/enter a valid email/i)).toBeOnTheScreen();
});
Enter fullscreen mode Exit fullscreen mode

Tests the real user-facing contract. Survives presentation refactors. Catches the 422 path end-to-end.


The Complete .cursorrules File

Drop this in the repo root. Cursor and Claude Code both pick it up.

# React Native — Production Patterns

## TypeScript & Typed Navigation
- strict: true, noUncheckedIndexedAccess, exactOptionalPropertyTypes,
  noImplicitOverride, noFallthroughCasesInSwitch.
- No `any`; unknown external data typed `unknown`, narrowed via Zod
  or type predicates.
- React Navigation with a typed RootStackParamList; screens use
  NativeStackScreenProps<...>; useNavigation typed.
- Explicit prop interfaces; discriminated unions for component variants.
- Zod schemas at API / deep-link / push-payload boundaries.

## Lists
- FlashList for anything > 20 items; FlatList banned otherwise.
- renderItem hoisted/memoized; keyExtractor stable; estimatedItemSize set.
- Item component wrapped in React.memo with stable prop refs.
- No new style objects in renderItem; StyleSheet.create at module scope.
- No ScrollView parent around a list; no nested scrolling without
  FlashList's onEndReached.
- useInfiniteQuery for pagination; FastImage/expo-image with explicit
  size and cache policy.

## Animations & Gestures
- react-native-reanimated v3 for all new animations. Legacy `Animated`
  banned for new code.
- useSharedValue + useAnimatedStyle; withTiming/withSpring/withSequence/withRepeat.
- Worklets are pure; use runOnJS to hop to JS.
- react-native-gesture-handler for gestures; PanResponder banned.
- Animate transform/opacity; layout props only when unavoidable.
- useNativeDriver: true where legacy Animated remains.

## Hooks
- react-hooks/exhaustive-deps at error; include navigation/route in deps.
- Ref pattern for stable callbacks instead of lying deps.
- useMemo only for expensive computations / stable refs to memoized children.
- useCallback only for deps arrays / memoized children props.
- State updater functions when new value depends on old.
- useReducer for correlated state transitions.

## Server State
- @tanstack/react-query for all server data; useEffect+fetch banned.
- QueryClient with staleTime, gcTime, retry, refetchOnReconnect.
- Typed hierarchical queryKeys factory; no inline string arrays.
- Mutations with optimistic updates + targeted invalidation.
- Offline: onlineManager + NetInfo; persist via AsyncStorage or MMKV.
- Infinite scroll via useInfiniteQuery + FlashList onEndReached.

## Platform / Safe Area / Keyboard
- react-native-safe-area-context; no hardcoded 44/34/20.
- KeyboardAvoidingView with Platform.select behavior; or
  react-native-keyboard-controller.
- TextInputs always set keyboardType/autoCapitalize/autoCorrect/
  textContentType/autoComplete/returnKeyType.
- Platform.select over nested ternaries.
- useWindowDimensions, not Dimensions.get at module scope.
- useColorScheme for light/dark; respect PixelRatio.getFontScale.

## Errors
- Sentry (or equivalent) initialized in index.js pre-import.
- ErrorBoundary (react-error-boundary) at every screen root.
- global.ErrorUtils handler for unhandled JS; onunhandledrejection too.
- Typed error classes (AppError subclasses); no catch with message-string.
- Recoverable errors -> toast + (optional) reporter; unexpected -> report + rethrow.

## Testing
- @testing-library/react-native; queries by role/label/testID in that order.
- Shared renderWithProviders wraps Query, SafeArea, navigation mock, GH.
- MSW (msw/native) for HTTP; no client-level MagicMock.
- userEvent over fireEvent. No full-tree snapshots.
- Detox for E2E; waitFor + toBeVisible, never sleep(ms).
- Coverage >80% aggregate; mutation tests on business-critical modules.
Enter fullscreen mode Exit fullscreen mode

End-to-End Example: A Screen That Lists Users With Search

Without rules: FlatList, useEffect fetch, hardcoded insets, plain Animated.

export function UsersScreen() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users?q=${query}`)
      .then((r) => r.json())
      .then((u) => setUsers(u))
      .finally(() => setLoading(false));
  }, [query]);

  return (
    <View style={{ paddingTop: 44 }}>
      <TextInput value={query} onChangeText={setQuery} />
      {loading ? (
        <ActivityIndicator />
      ) : (
        <FlatList
          data={users}
          renderItem={({ item }) => <Text style={{ padding: 16 }}>{item.name}</Text>}
        />
      )}
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

With rules: TanStack Query, FlashList, debounced typed input, safe-area, memoized row.

const styles = StyleSheet.create({
  row: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#eee' },
});

const UserRow = React.memo<{ user: User; onPress: (id: string) => void }>(function UserRow({
  user,
  onPress,
}) {
  const handle = useCallback(() => onPress(user.id), [onPress, user.id]);
  return (
    <Pressable accessibilityRole="button" style={styles.row} onPress={handle}>
      <Text>{user.name}</Text>
    </Pressable>
  );
});

export function UsersScreen({ navigation }: UsersProps) {
  const insets = useSafeAreaInsets();
  const [query, setQuery] = useState('');
  const debounced = useDebouncedValue(query, 200);

  const { data: users = [], isPending, isError, refetch, isRefetching } = useUsers({
    q: debounced,
  });

  const keyExtractor = useCallback((u: User) => u.id, []);
  const onOpen = useCallback(
    (id: string) => navigation.navigate('UserDetails', { userId: id }),
    [navigation]
  );
  const renderItem = useCallback(
    ({ item }: { item: User }) => <UserRow user={item} onPress={onOpen} />,
    [onOpen]
  );

  return (
    <View style={{ flex: 1, paddingTop: insets.top }}>
      <TextInput
        value={query}
        onChangeText={setQuery}
        placeholder="Search users"
        accessibilityLabel="Search users"
        autoCapitalize="none"
        autoCorrect={false}
        returnKeyType="search"
      />
      {isError ? (
        <ErrorView onRetry={refetch} />
      ) : isPending ? (
        <ActivityIndicator />
      ) : (
        <FlashList
          data={users}
          renderItem={renderItem}
          keyExtractor={keyExtractor}
          estimatedItemSize={56}
          refreshing={isRefetching}
          onRefresh={refetch}
        />
      )}
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Debounced, cached, recycled, safe-area-aware, accessible, retryable.


Get the Full Pack

These eight rules cover the React Native patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — strict-TypeScript, FlashList-backed, Reanimated-driven, Query-cached, safe-area-correct, boundary-wrapped, RTL-tested React Native, without having to re-prompt.

If you want the expanded pack — these eight plus rules for Expo / bare-workflow specifics, EAS Build pipelines, native-module authoring with Turbo Modules, Hermes perf tuning, over-the-air updates, deep-link discipline, and the testing conventions I use on production React Native apps — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship React Native you would actually merge.

Top comments (0)