mcpbeat Sign in

React Native Agent Skill

Use when writing the JS/TS inside a React Native or Expo app — screens, Expo Router navigation, lists, Reanimated gestures, platform forks, offline state, native modules — or killing jank and render storms. NOT eas build/submit/OTA/config-plugin (that is `expo`), NOT a Dart app (that is `flutter`), NOT web React/DOM (that is `react` or `nextjs`).

7k tokens
context cost
the whole folder, loaded on every use
6
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill react-native

What comes with it

16 721 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/native-modules.md
references/performance-debugging.md
scripts/verify.sh

The instruction itself

10 sections, as written by the author

React Native (app code)

Hand-off — the app vs its motion. Animation and gestures in a React Native app — Reanimated

worklets on the UI runtime, gesture handoff, sheets, press feedback, haptics — are

../motion-craft/SKILL.md's, whose guidance is platform-agnostic and applies to bare React Native as well as Expo.

You write the code that runs *inside* the app: render, navigate, animate, list, persist, and author native modules. Mobile is split across two skills by verb — run the verb test before doing anything:

| Verb in the request | Skill |

|---|---|

| render / navigate / animate / list / persist / author-native-module / debug-runtime | react-native (here) |

| build / submit / update (OTA) / prebuild / config-plugin / eas.json / channel / runtime-version | ../expo/SKILL.md |

Assume the New Architecture is on (Fabric + Turbo Modules). It is mandatory from RN 0.82 / Expo SDK 55 — RN 0.82 (2025-10-08) ignores any disable flag, and SDK 55 is New-Arch-only. SDK 54 (RN 0.81) was the last that could opt out. Never write newArchEnabled: false to dodge a bug; fix the bug. Why: the flag is a no-op on current versions, so the "fix" silently does nothing.

Reference SDK→RN map (accessed 2026-06-02): SDK 53 = RN 0.79, SDK 54 = RN 0.81 (React Compiler GA), SDK 55 = RN 0.83.

Project shape — pick a router first

This is the one real branch at scaffold time:

| Use | When |

|---|---|

| Expo Router (file-based) | New apps, web parity, deep links, typed routes. The recommended default. It is a layer on top of React Navigation; both are Expo-team maintained. |

| React Navigation v7 (static API) | Brownfield apps, native integration, highly bespoke custom transitions where you need imperative control. |

Default file tree for an Expo Router app:

app/                  # routes — file system IS the navigation
  (auth)/             # group: unauthenticated screens
  (app)/              # group: authenticated screens
    (tabs)/           # nested tab navigator
  _layout.tsx         # root layout + providers
components/           # dumb, reusable UI
features/<domain>/    # feature-scoped screens, hooks, components
lib/                  # clients, query setup, storage, utils

Keep route files thin: a route file wires params and renders a feature component. Put logic in features/. Why: routes get re-mounted by navigation; business logic in them is hard to test and re-runs unexpectedly.

Type your routes. With Expo Router, enable typed routes and let useLocalSearchParams<{ id: string }>() carry the contract. Why: deep links and back-stack restoration pass strings you will otherwise misread.

Gate auth at the group layout, declaratively — not imperatively inside an effect:

// app/(app)/_layout.tsx — Bad: imperative nav in an effect races the first paint
export default function AppLayout() {
  const { user } = useAuth();
  useEffect(() => {
    if (!user) router.replace('/(auth)/sign-in'); // flashes protected UI first
  }, [user]);
  return <Stack />;
}
// app/(app)/_layout.tsx — Good: redirect before children mount
import { Redirect, Stack } from 'expo-router';
export default function AppLayout() {
  const { user, loading } = useAuth();
  if (loading) return null;          // or a splash
  if (!user) return <Redirect href="/(auth)/sign-in" />;
  return <Stack />;
}

Modals/sheets: declare presentation on the screen, not by pushing a styled full-screen route — <Stack.Screen options={{ presentation: 'modal' }} />. Configure deep links via the scheme + the linking config so a cold-start link resolves to the right nested route, not just the root. A deep link landing on the wrong screen is almost always a group/segment mismatch between the URL and the app/ tree.

Lists & performance

Switch from FlatList to FlashList (Shopify) once a list crosses ~100 items, has variable row heights, or renders images. Why: FlashList recycles views instead of mounting one per row, so memory and scroll jank stay flat as the list grows.

Three things every long list needs:

  • A stable keyExtractor — return a real id, never the index. Index keys defeat recycling and reorder rows on insert.
  • getItemType when rows differ structurally (header vs product vs ad). Why: it lets FlashList recycle within a type instead of remeasuring.
  • A memoized row component with no inline closures or style objects in renderItem.
// Bad: new fn + new style object every parent render -> every visible row re-renders
<FlashList
  data={items}
  renderItem={({ item }) => (
    <Pressable style={{ padding: 12 }} onPress={() => open(item.id)}>
      <Text>{item.title}</Text>
    </Pressable>
  )}
/>
// Good: stable refs, memoized row, typed rows
const Row = memo(function Row({ item }: { item: Product }) {
  const open = useOpenProduct();          // stable from context/store
  return (
    <Pressable style={styles.row} onPress={() => open(item.id)}>
      <Text>{item.title}</Text>
    </Pressable>
  );
});

<FlashList
  data={items}
  keyExtractor={(it) => it.id}
  getItemType={(it) => it.kind}
  renderItem={({ item }) => <Row item={item} />}
/>

FlashList caveat: rows can flash into the wrong position for a split second if mounted while entry animations run. Disable entry animations on the initial mount.

React Compiler (GA in SDK 54) auto-memoizes components and reduces manual useMemo/useCallback — but it does not remove the need for virtualization, stable keys, or getItemType. Don't delete your list discipline because the compiler is on. For re-render hunting and profiling, see references/performance-debugging.md.

Animation & gestures

Reanimated worklets run on the UI thread, so animations keep going at 60fps even when JS is busy. Reanimated 4 (stable Oct 2025) requires the New Architecture / RN 0.76+ and adds CSS-style animations while keeping the worklet API. Many teams stay on Reanimated 3 for dependency compatibility — match the project's installed major.

Two rules that fix most gesture bugs:

  • Wrap gesture objects in useMemo. Why: a new gesture object each render reattaches the recognizer and drops in-flight touches.
  • Cross to JS only at the boundary with runOnJS. Why: calling a JS-thread function directly from a worklet crashes; everything inside the worklet must stay on the UI thread.
// Good: memoized gesture, UI-thread shared value, JS only at the end
const x = useSharedValue(0);
const pan = useMemo(
  () =>
    Gesture.Pan()
      .onUpdate((e) => { x.value = e.translationX; })            // UI thread
      .onEnd(() => { runOnJS(onSwiped)(); x.value = withSpring(0); }), // boundary
  [onSwiped],
);

Use Reanimated 4 CSS animations for simple declarative cases (fades, simple transitions); use worklets for gesture-driven or physics-based motion. Never animate layout on the JS thread when a shared value will do it on the UI thread.

Platform-conditional code

  • Small forks: Platform.select({ ios: 12, android: 8 }) or Platform.OS === 'ios'.
  • Whole-component forks: Button.ios.tsx / Button.android.tsx — the bundler picks the right one; import ./Button with no extension.
  • Safe area: use react-native-safe-area-context insets, not hardcoded notch padding. Why: insets differ per device and orientation. Palette, type scale and layout intent are not decided here → ../design/SKILL.md.
  • KeyboardAvoidingView behaves differently per platform — padding on iOS, often height or nothing on Android; test both, don't assume the iOS behavior ports.
  • Set the status bar style explicitly per screen via expo-status-bar.

Data & state

  • Server state → TanStack Query. It owns caching, dedupe, retries, and background refetch. Don't hand-roll loading flags in component state.
  • Persistence: MMKV for fast synchronous key/value (auth tokens, flags, small prefs); expo-sqlite + Drizzle for relational or offline-first data. Pick by shape, not habit.
  • Don't reach for Redux by default. Server state lives in Query; the small amount of true client state fits in context or Zustand.
  • Offline-first: persist the Query cache and treat the network as an enhancement, not a precondition.

Native modules — pick the path

| Path | When | Cost |

|---|---|---|

| Plain JS wrapper of an existing lib | A maintained npm package already bridges the native API | None — do this first |

| Expo Modules API (Swift/Kotlin) | App uses Expo; you want clean Swift/Kotlin, no codegen | Requires Expo; least boilerplate |

| Turbo Module (codegen from TS specs) | Bare RN, or you need the RN-default path | Codegen specs + more boilerplate |

| Nitro Modules | Hot path needing max throughput | Emerging; weigh maturity |

Minimal Expo Module skeleton (definition lives in the module's Swift/Kotlin):

// ios/MyModule.swift
import ExpoModulesCore
public class MyModule: Module {
  public func definition() -> ModuleDefinition {
    Name("MyModule")
    Function("hello") { (name: String) -> String in "Hi \(name)" }
  }
}
// index.ts — typed JS surface
import { requireNativeModule } from 'expo-modules-core';
const MyModule = requireNativeModule('MyModule');
export function hello(name: string): string { return MyModule.hello(name); }

Full Expo Module (Swift + Kotlin + view), the Turbo Module codegen-spec walkthrough, packaging, and autolinking → references/native-modules.md.

Debugging

  • Read Hermes stack traces from the bottom up; the JS frame that matters is usually above the native bridge frames.
  • "Works on iOS, breaks on Android" (or vice versa) → run the platform-divergence checklist in references/performance-debugging.md before guessing.
  • A red screen citing the interop layer is usually an old-architecture lib running under Fabric — update the lib or find a New-Arch-ready replacement; do not disable New Arch.
  • A fault that resists all of the above needs systematic isolation, not more guesses → ../debug/SKILL.md.

Anti-patterns

| Anti-pattern | Why it hurts | Do instead |

|---|---|---|

| FlatList for 5k rows / image feeds | Mounts a view per row; jank + memory blowup | FlashList with getItemType + stable keyExtractor |

| Inline arrow / style object in renderItem | New ref every render re-renders every visible row | Memoized Row, StyleSheet.create, stable handlers |

| Gesture object built inline each render | Reattaches recognizer, drops in-flight touches | Wrap in useMemo keyed on real deps |

| setState in render path | Render loop / re-render storm on every keystroke | Derive in render or move to an event handler |

| Imperative router.replace in an effect to gate auth | Flashes protected UI, races first paint | <Redirect> at the group layout |

| newArchEnabled: false to dodge a New-Arch bug | No-op on RN 0.82+/SDK 55; hides the real cause | Update the offending lib; fix the bug |

| Animating layout on the JS thread | Drops frames when JS is busy | Reanimated shared value on the UI thread |

| Index as list key | Breaks recycling, reorders rows on insert | Stable domain id |

| Doing native config / eas.json / OTA here | Wrong skill; this is app code | ../expo/SKILL.md |

| Hardcoded notch/safe-area padding | Wrong on other devices/orientations | react-native-safe-area-context insets |

How to use it

Copy the folder

Take ericrisco/react-native from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.