All skills
callstackincubator avatar

/react-native-best-practices

@fa0bad0 official

Provides React Native performance optimization guidelines for FPS, TTI, bundle size, memory leaks, re-renders, and animations. Applies to tasks involving Hermes optimization, JS thread blocking, bridge overhead, FlashList, native modules, or debugging jank and frame drops.

Use this Skill: https://skilld.dev/gh/callstackincubator/agent-skills/react-native-best-practices

This session only. Nothing lands on disk.

referencesjs-bottomsheet.md

≈3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Skill: Bottom Sheet Best Practices

Optimize @gorhom/bottom-sheet for smooth 60 FPS by keeping gesture/scroll-driven state on the UI thread.

Quick Pattern

Incorrect (can re-enter JS repeatedly during interaction — full subtree re-render):

const handleAnimate = useCallback((fromIndex, toIndex) => {
  setIsExpanded(toIndex > 0); // re-renders entire tree
}, []);

<BottomSheet onAnimate={handleAnimate}>
  <ExpensiveContent isExpanded={isExpanded} />
</BottomSheet>

Correct (stays on UI thread — zero re-renders):

const animatedIndex = useSharedValue(0);

const overlayStyle = useAnimatedStyle(() => ({
  opacity: interpolate(
    animatedIndex.value,
    [0, 1],
    [0, 0.5],
    Extrapolation.CLAMP
  ),
}));

<BottomSheet animatedIndex={animatedIndex}>
  <ExpensiveContent />
</BottomSheet>
<Animated.View style={[styles.overlay, overlayStyle]} />

When to Use

  • Implementing or optimizing a bottom sheet with @gorhom/bottom-sheet
  • Bottom sheet gestures cause jank or dropped frames
  • Scroll inside bottom sheet triggers excessive re-renders
  • Context provider wrapping bottom sheet re-renders the entire subtree
  • Visual-only state (shadow, opacity, footer visibility) managed with useState
  • Need to choose between BottomSheet and BottomSheetModal
  • Scrollable content inside bottom sheet doesn't coordinate with gestures
  • Keyboard doesn't interact properly with the sheet

Prerequisites

  • Check the official @gorhom/bottom-sheet versioning / compatibility table first.
  • If your app is on @gorhom/bottom-sheet below v5, upgrade to v5 before applying the patterns in this skill.
  • @gorhom/bottom-sheet v5 is the current maintained line and is built for react-native-reanimated v3.
  • react-native-reanimated v4 may work in some apps, but the bottom-sheet docs do not officially guarantee it. Decide explicitly whether to stay on v3 or try v4 and validate thoroughly on device.
  • react-native-gesture-handler v2+
npm install @gorhom/bottom-sheet@^5 react-native-reanimated@^3 react-native-gesture-handler

Note: In v5, enableDynamicSizing defaults to true. If you need fixed snap-point indexing or do not want the library to insert a dynamic snap point based on content height, set enableDynamicSizing={false} explicitly.

Problem Description

Bottom-sheet gesture, animation, and scroll callbacks that update React state can re-render the sheet subtree during interaction. In practice, callbacks like onAnimate may run repeatedly as the sheet retargets animations, which can cause visible jank if they drive expensive React updates.

Step-by-Step Instructions

1. Convert Gesture-Driven State to SharedValue

Avoid React state for gesture-driven visual state. Update a shared value and consume it via useAnimatedStyle.

Before:

const [shadowOpacity, setShadowOpacity] = useState(0);

const handleAnimate = useCallback((fromIndex, toIndex) => {
  setShadowOpacity(toIndex > 0 ? 0.3 : 0);
}, []);

<BottomSheet onAnimate={handleAnimate}>
  <View style={{ shadowOpacity }}>
    <HeavyContent />
  </View>
</BottomSheet>

After:

const animatedIndex = useSharedValue(0);

const shadowStyle = useAnimatedStyle(() => ({
  shadowOpacity: interpolate(
    animatedIndex.value,
    [0, 1],
    [0, 0.3],
    Extrapolation.CLAMP
  ),
}));

<BottomSheet animatedIndex={animatedIndex}>
  <Animated.View style={shadowStyle}>
    <HeavyContent />
  </Animated.View>
</BottomSheet>

2. Drive Sheet-Index Visibility via useAnimatedReaction

Toggling content based on sheet index via {showFooter && <Footer/>} causes mount/unmount cycles on every snap. Instead, always mount, animate visibility from animatedIndex, and bridge only the minimal boolean needed for pointerEvents/accessibility — scoped to a wrapper so the full tree doesn't re-render.

Before:

const [showFooter, setShowFooter] = useState(false);

// re-mounts footer on every toggle
{showFooter && <Footer />}

After:

const SheetVisibilityWrapper = ({ animatedIndex, threshold = 1, children }) => {
  const [isInteractive, setIsInteractive] = useState(false);

  const style = useAnimatedStyle(() => {
    const progress = interpolate(
      animatedIndex.value,
      [threshold - 0.01, threshold],
      [0, 1],
      Extrapolation.CLAMP
    );

    return {
      opacity: progress,
      transform: [{ translateY: interpolate(progress, [0, 1], [50, 0]) }],
    };
  });

  useAnimatedReaction(
    () => animatedIndex.value >= threshold,
    (visible, prev) => {
      if (visible !== prev) runOnJS(setIsInteractive)(visible);
    }
  );

  return (
    <Animated.View
      style={style}
      pointerEvents={isInteractive ? 'auto' : 'none'}
      accessibilityElementsHidden={!isInteractive}
      importantForAccessibility={isInteractive ? 'auto' : 'no-hide-descendants'}
    >
      {children}
    </Animated.View>
  );
};

// Usage:
<SheetVisibilityWrapper animatedIndex={animatedIndex}>
  <Footer />
</SheetVisibilityWrapper>

3. Keep Scroll-Driven Logic off the JS Thread

BottomSheetScrollView ignores scrollEventThrottle, so setting it is not an optimization. Keep JS onScroll work minimal, or move scroll-driven logic to useAnimatedScrollHandler (see js-animations-reanimated.md) so it stays on the UI thread:

const scrollHandler = useAnimatedScrollHandler((event) => {
  scrollY.value = event.contentOffset.y;
});

<BottomSheetScrollView onScroll={scrollHandler}>
  <Content />
</BottomSheetScrollView>

4. Use Library-Provided Components and Props

Scrollables — always use these instead of React Native built-ins inside a bottom sheet:

import {
  BottomSheetScrollView,
  BottomSheetFlatList,
  BottomSheetSectionList,
} from '@gorhom/bottom-sheet';

// FlashList v2: BottomSheetFlashList is deprecated.
// Create the scroll component, then pass it to FlashList.
import { useBottomSheetScrollableCreator } from '@gorhom/bottom-sheet';
import { FlashList } from '@shopify/flash-list';

const BottomSheetFlashListScrollComponent = useBottomSheetScrollableCreator();

<BottomSheet snapPoints={snapPoints} enableDynamicSizing={false}>
  <FlashList
    data={data}
    keyExtractor={(item) => item.id}
    renderItem={renderItem}
    renderScrollComponent={BottomSheetFlashListScrollComponent}
  />
</BottomSheet>

Key props:

Prop Purpose
containerHeight Provide to skip extra measurement re-render on mount
enableDynamicSizing={false} Use when you want fixed snap-point indexing and do not want a dynamic content-height snap point inserted
animatedIndex SharedValue for continuous index tracking on UI thread
animatedPosition SharedValue for continuous position tracking on UI thread
onChange Fires on snap completion only (discrete) — use for analytics/side effects
onAnimate Fires before each animation start/retarget — use sparingly, because it can run repeatedly during interaction

5. BottomSheetModal Setup

import {
  BottomSheetModal,
  BottomSheetModalProvider,
} from '@gorhom/bottom-sheet';

const App = () => (
  <BottomSheetModalProvider>
    <BottomSheetModal
      ref={modalRef}
      snapPoints={snapPoints}
      enableDismissOnClose={true}
    >
      <Content />
    </BottomSheetModal>
  </BottomSheetModalProvider>
);

iOS layering fix — use FullWindowOverlay to render above native navigation:

import { FullWindowOverlay } from 'react-native-screens';

<BottomSheetModal
  containerComponent={(props) => <FullWindowOverlay>{props.children}</FullWindowOverlay>}
>

6. Keyboard Handling

<BottomSheet
  snapPoints={snapPoints}
  enableDynamicSizing={false}
  keyboardBehavior="interactive"    // 'extend' | 'fillParent' | 'interactive'
  keyboardBlurBehavior="restore"    // reset sheet position when keyboard dismisses
  enableBlurKeyboardOnGesture={true} // dismiss keyboard on drag
>
  <BottomSheetTextInput
    placeholder="Type here..."
    style={styles.input}
  />
</BottomSheet>
keyboardBehavior Effect
extend Sheet grows to accommodate keyboard
fillParent Sheet fills parent when keyboard appears
interactive Sheet follows keyboard position interactively

Prefer BottomSheetTextInput inside a bottom sheet. If you need a custom input, copy the focus/blur handlers from the library's BottomSheetTextInput implementation so keyboard handling still works correctly.

Derived Animations with animatedPosition

Use the animatedPosition shared value for smooth derived UI that stays on the UI thread:

const animatedPosition = useSharedValue(0);

const backdropStyle = useAnimatedStyle(() => ({
  opacity: interpolate(
    animatedPosition.value,
    [0, 300],
    [0.5, 0],
    Extrapolation.CLAMP
  ),
}));

<BottomSheet animatedPosition={animatedPosition} snapPoints={snapPoints}>
  <Content />
</BottomSheet>
<Animated.View style={[StyleSheet.absoluteFill, backdropStyle]} pointerEvents="none" />

Native Alternative: react-native-true-sheet

If your app already runs on New Architecture (Fabric) and needs a standard native-feeling sheet, evaluate @lodev09/react-native-true-sheet. Keep @gorhom/bottom-sheet when you need fine-grained Reanimated customization, custom gestures, or a mature cross-platform fallback.

Scenario Recommendation
Need deep JS customization (custom gestures, animated derived UI) @gorhom/bottom-sheet
Standard sheet with native feel + accessibility react-native-true-sheet
Legacy Architecture (no Fabric) @gorhom/bottom-sheet (true-sheet v3+ requires Fabric)
Web support needed Either (true-sheet uses @gorhom/bottom-sheet on web internally)
npm install @lodev09/react-native-true-sheet

Common Pitfalls

  • Using onChange for continuous position tracking — it fires on snap completion only (discrete). Use animatedPosition or animatedIndex shared values instead.
  • Starting timing animations inside sheet-index style worklets — derive gesture-linked visuals with interpolate; reserve withTiming for explicit state transitions.
  • Forgetting pointerEvents='none' on always-mounted hidden elements — invisible elements still capture touches.
  • Missing accessibility attributes on hidden elements — add accessibilityElementsHidden and importantForAccessibility='no-hide-descendants'.
  • Bundling independent state values in one context — see js-atomic-state.md for splitting patterns.
  • Assuming enableDynamicSizing must be disabled whenever you pass snapPoints — it does not have to be, but leaving it enabled can insert an additional snap point and change indexing.
  • Using React Native ScrollView/FlatList inside bottom sheet — gestures won't coordinate. Use BottomSheetScrollView, BottomSheetFlatList, etc.
  • Gesture conflicts with React Native touchables — when touches do not respond inside the sheet, use the touchable components exported by @gorhom/bottom-sheet, especially on Android.
  • Not providing containerHeight — causes an extra re-render on mount for measurement.
  • Using a custom TextInput without porting the library's focus/blur handlers — keyboard handling will be incomplete. Prefer BottomSheetTextInput unless you need a custom input.

Related Skills

Source: SKILL.md on GitHub

1 alert17d5 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    This skill provides a comprehensive performance optimization guide for React Native applications. It contains standard developer workflows using command-line tools and patterns for advanced techniques like remote code splitting, which include detailed security hardening advice.

  • Socket17d

    No alerts

  • Snyk17d

    Risk: LOW · No issues

  • Runlayer7mo

    11/31 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at fa0bad0. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 months ago
  • Performance
  • react-native
  • optimization
  • profiling
  • fps
  • bundle-size
  • memory
  • hermes
  • flatlist
  • animations

README badge

README badge for callstackincubator/agent-skills/react-native-best-practices

Provides React Native performance optimization guidelines covering FPS, TTI, bundle size, memory leaks, re-renders, and animations. Includes reference documentation for Hermes, FlashList, Turbo Modules, React Compiler, Reanimated, and profiling with Xcode and Android Studio.

Generated from the current SKILL.md.

Does this skill cover Expo apps?
Yes. The guidelines apply to both bare React Native and Expo projects. Expo SDK 52+ supports tree shaking and Re.Pack code splitting.
What performance metrics should I measure first?
Start with FPS (use React Native DevTools), Time To Interactive (TTI), bundle size, and memory growth. The skill emphasizes measuring baseline metrics before and after optimization to validate improvements.
Does this cover Hermes optimization?
Yes. The skill includes Hermes-specific guidance such as disabling JS bundle compression on Android to enable mmap, and checking Hermes API coverage before removing polyfills.
What if I'm using a list library other than FlatList or FlashList?
The skill prioritizes FlatList and FlashList as virtualized replacements for ScrollView. It includes library version checks (e.g., FlashList v2 API changes) but focuses on these two libraries.
Does this include native profiling guidance?
Yes. The skill covers profiling with Xcode Instruments (iOS) and Android Studio CPU Profiler, plus guidance on native threading, Turbo Modules, and C++ performance-critical code.

Generated from the current SKILL.md. These answers refresh after source changes.