Skip to content

Commit d183420

Browse files
BogiKaythymikee
andauthored
feat: add bottom sheet optimization skill (#45)
* feat: bottomsheet optimization * fix: correct bottom sheet skill guidance * fix: correct bottom sheet perf guidance and doc links * fix: clarify bottom sheet compatibility guidance --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent 569d66c commit d183420

6 files changed

Lines changed: 332 additions & 0 deletions

File tree

skills/react-native-best-practices/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ Full documentation with code examples in [references/][references]:
149149
| [js-concurrent-react.md][js-concurrent-react] | HIGH | useDeferredValue, useTransition |
150150
| [js-react-compiler.md][js-react-compiler] | HIGH | Automatic memoization |
151151
| [js-animations-reanimated.md][js-animations-reanimated] | MEDIUM | Reanimated worklets |
152+
| [js-bottomsheet.md][js-bottomsheet] | HIGH | Bottom sheet optimization |
152153
| [js-uncontrolled-components.md][js-uncontrolled-components] | HIGH | TextInput optimization |
153154

154155
### Native (`native-*`)
@@ -203,6 +204,7 @@ grep -l "bundle" references/
203204
| Large app size | [bundle-analyze-app.md][bundle-analyze-app][bundle-r8-android.md][bundle-r8-android] |
204205
| Memory growing | [js-memory-leaks.md][js-memory-leaks] or [native-memory-leaks.md][native-memory-leaks] |
205206
| Animation drops frames | [js-animations-reanimated.md][js-animations-reanimated] |
207+
| Bottom sheet jank/re-renders | [js-bottomsheet.md][js-bottomsheet][js-animations-reanimated.md][js-animations-reanimated] |
206208
| List scroll jank | [js-lists-flatlist-flashlist.md][js-lists-flatlist-flashlist] |
207209
| TextInput lag | [js-uncontrolled-components.md][js-uncontrolled-components] |
208210
| Native module slow | [native-turbo-modules.md][native-turbo-modules][native-threading-model.md][native-threading-model] |
@@ -217,6 +219,7 @@ grep -l "bundle" references/
217219
[js-concurrent-react]: references/js-concurrent-react.md
218220
[js-react-compiler]: references/js-react-compiler.md
219221
[js-animations-reanimated]: references/js-animations-reanimated.md
222+
[js-bottomsheet]: references/js-bottomsheet.md
220223
[js-uncontrolled-components]: references/js-uncontrolled-components.md
221224
[native-turbo-modules]: references/native-turbo-modules.md
222225
[native-sdks-over-polyfills]: references/native-sdks-over-polyfills.md

skills/react-native-best-practices/references/js-animations-reanimated.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,5 @@ withSpring(value, {
251251
## Related Skills
252252

253253
- [js-measure-fps.md](./js-measure-fps.md) - Verify animation frame rate
254+
- [js-bottomsheet.md](./js-bottomsheet.md) - Keep bottom sheet visual state on the UI thread
254255
- [js-concurrent-react.md](./js-concurrent-react.md) - React-level deferral with useTransition

skills/react-native-best-practices/references/js-atomic-state.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,5 +241,6 @@ const TodoList = () => {
241241

242242
## Related Skills
243243

244+
- [js-bottomsheet.md](./js-bottomsheet.md) - Avoid context-driven bottom sheet subtree re-renders
244245
- [js-react-compiler.md](./js-react-compiler.md) - Automatic memoization alternative
245246
- [js-profile-react.md](./js-profile-react.md) - Verify re-render reduction
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
---
2+
title: Bottom Sheet
3+
impact: HIGH
4+
tags: bottom-sheet, gorhom, re-renders, shared-values, gestures, context, scrollable, modal, keyboard
5+
---
6+
7+
# Skill: Bottom Sheet Best Practices
8+
9+
Optimize `@gorhom/bottom-sheet` for smooth 60 FPS by keeping gesture/scroll-driven state on the UI thread.
10+
11+
## Quick Pattern
12+
13+
**Incorrect (can re-enter JS repeatedly during interaction — full subtree re-render):**
14+
15+
```jsx
16+
const handleAnimate = useCallback((fromIndex, toIndex) => {
17+
setIsExpanded(toIndex > 0); // re-renders entire tree
18+
}, []);
19+
20+
<BottomSheet onAnimate={handleAnimate}>
21+
<ExpensiveContent isExpanded={isExpanded} />
22+
</BottomSheet>
23+
```
24+
25+
**Correct (stays on UI thread — zero re-renders):**
26+
27+
```jsx
28+
const animatedIndex = useSharedValue(0);
29+
30+
const overlayStyle = useAnimatedStyle(() => ({
31+
opacity: withTiming(animatedIndex.value > 0 ? 0.5 : 0),
32+
}));
33+
34+
<BottomSheet animatedIndex={animatedIndex}>
35+
<ExpensiveContent />
36+
</BottomSheet>
37+
<Animated.View style={[styles.overlay, overlayStyle]} />
38+
```
39+
40+
## When to Use
41+
42+
- Implementing or optimizing a bottom sheet with `@gorhom/bottom-sheet`
43+
- Bottom sheet gestures cause jank or dropped frames
44+
- Scroll inside bottom sheet triggers excessive re-renders
45+
- Context provider wrapping bottom sheet re-renders the entire subtree
46+
- Visual-only state (shadow, opacity, footer visibility) managed with `useState`
47+
- Need to choose between `BottomSheet` and `BottomSheetModal`
48+
- Scrollable content inside bottom sheet doesn't coordinate with gestures
49+
- Keyboard doesn't interact properly with the sheet
50+
51+
## Prerequisites
52+
53+
- Check the official [`@gorhom/bottom-sheet` versioning / compatibility table](https://github.com/gorhom/react-native-bottom-sheet#versioning) first.
54+
- If your app is on `@gorhom/bottom-sheet` below v5, upgrade to v5 before applying the patterns in this skill.
55+
- `@gorhom/bottom-sheet` v5 is the current maintained line and is built for `react-native-reanimated` v3.
56+
- `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.
57+
- `react-native-gesture-handler` v2+
58+
59+
```bash
60+
npm install @gorhom/bottom-sheet@^5 react-native-reanimated@^3 react-native-gesture-handler
61+
```
62+
63+
> **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.
64+
65+
## Problem Description
66+
67+
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.
68+
69+
## Step-by-Step Instructions
70+
71+
### 1. Convert Gesture-Driven State to SharedValue
72+
73+
Avoid React state for gesture-driven visual state. Update a shared value and consume it via `useAnimatedStyle`.
74+
75+
**Before:**
76+
77+
```jsx
78+
const [shadowOpacity, setShadowOpacity] = useState(0);
79+
80+
const handleAnimate = useCallback((fromIndex, toIndex) => {
81+
setShadowOpacity(toIndex > 0 ? 0.3 : 0);
82+
}, []);
83+
84+
<BottomSheet onAnimate={handleAnimate}>
85+
<View style={{ shadowOpacity }}>
86+
<HeavyContent />
87+
</View>
88+
</BottomSheet>
89+
```
90+
91+
**After:**
92+
93+
```jsx
94+
const animatedIndex = useSharedValue(0);
95+
96+
const shadowStyle = useAnimatedStyle(() => ({
97+
shadowOpacity: withTiming(animatedIndex.value > 0 ? 0.3 : 0),
98+
}));
99+
100+
<BottomSheet animatedIndex={animatedIndex}>
101+
<Animated.View style={shadowStyle}>
102+
<HeavyContent />
103+
</Animated.View>
104+
</BottomSheet>
105+
```
106+
107+
### 2. Drive Sheet-Index Visibility via `useAnimatedReaction`
108+
109+
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.
110+
111+
**Before:**
112+
113+
```jsx
114+
const [showFooter, setShowFooter] = useState(false);
115+
116+
// re-mounts footer on every toggle
117+
{showFooter && <Footer />}
118+
```
119+
120+
**After:**
121+
122+
```jsx
123+
const SheetVisibilityWrapper = ({ animatedIndex, threshold = 1, children }) => {
124+
const [isInteractive, setIsInteractive] = useState(false);
125+
126+
const style = useAnimatedStyle(() => ({
127+
opacity: withTiming(animatedIndex.value >= threshold ? 1 : 0),
128+
transform: [{ translateY: withTiming(animatedIndex.value >= threshold ? 0 : 50) }],
129+
}));
130+
131+
useAnimatedReaction(
132+
() => animatedIndex.value >= threshold,
133+
(visible, prev) => {
134+
if (visible !== prev) runOnJS(setIsInteractive)(visible);
135+
}
136+
);
137+
138+
return (
139+
<Animated.View
140+
style={style}
141+
pointerEvents={isInteractive ? 'auto' : 'none'}
142+
accessibilityElementsHidden={!isInteractive}
143+
importantForAccessibility={isInteractive ? 'auto' : 'no-hide-descendants'}
144+
>
145+
{children}
146+
</Animated.View>
147+
);
148+
};
149+
150+
// Usage:
151+
<SheetVisibilityWrapper animatedIndex={animatedIndex}>
152+
<Footer />
153+
</SheetVisibilityWrapper>
154+
```
155+
156+
### 3. Keep Scroll-Driven Logic off the JS Thread
157+
158+
`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](./js-animations-reanimated.md)) so it stays on the UI thread:
159+
160+
```jsx
161+
const scrollHandler = useAnimatedScrollHandler((event) => {
162+
scrollY.value = event.contentOffset.y;
163+
});
164+
165+
<BottomSheetScrollView onScroll={scrollHandler}>
166+
<Content />
167+
</BottomSheetScrollView>
168+
```
169+
170+
### 4. Use Library-Provided Components and Props
171+
172+
**Scrollables** — always use these instead of React Native built-ins inside a bottom sheet:
173+
174+
```jsx
175+
import {
176+
BottomSheetScrollView,
177+
BottomSheetFlatList,
178+
BottomSheetSectionList,
179+
} from '@gorhom/bottom-sheet';
180+
181+
// FlashList v2: BottomSheetFlashList is deprecated.
182+
// Create the scroll component, then pass it to FlashList.
183+
import { useBottomSheetScrollableCreator } from '@gorhom/bottom-sheet';
184+
import { FlashList } from '@shopify/flash-list';
185+
186+
const BottomSheetFlashListScrollComponent = useBottomSheetScrollableCreator();
187+
188+
<BottomSheet snapPoints={snapPoints} enableDynamicSizing={false}>
189+
<FlashList
190+
data={data}
191+
keyExtractor={(item) => item.id}
192+
renderItem={renderItem}
193+
renderScrollComponent={BottomSheetFlashListScrollComponent}
194+
/>
195+
</BottomSheet>
196+
```
197+
198+
**Key props:**
199+
200+
| Prop | Purpose |
201+
|------|---------|
202+
| `containerHeight` | Provide to skip extra measurement re-render on mount |
203+
| `enableDynamicSizing={false}` | Use when you want fixed snap-point indexing and do not want a dynamic content-height snap point inserted |
204+
| `animatedIndex` | SharedValue for continuous index tracking on UI thread |
205+
| `animatedPosition` | SharedValue for continuous position tracking on UI thread |
206+
| `onChange` | Fires on snap **completion** only (discrete) — use for analytics/side effects |
207+
| `onAnimate` | Fires before each animation start/retarget — use sparingly, because it can run repeatedly during interaction |
208+
209+
### 5. BottomSheetModal Setup
210+
211+
```jsx
212+
import {
213+
BottomSheetModal,
214+
BottomSheetModalProvider,
215+
} from '@gorhom/bottom-sheet';
216+
217+
const App = () => (
218+
<BottomSheetModalProvider>
219+
<BottomSheetModal
220+
ref={modalRef}
221+
snapPoints={snapPoints}
222+
enableDismissOnClose={true}
223+
>
224+
<Content />
225+
</BottomSheetModal>
226+
</BottomSheetModalProvider>
227+
);
228+
```
229+
230+
**iOS layering fix** — use `FullWindowOverlay` to render above native navigation:
231+
232+
```jsx
233+
import { FullWindowOverlay } from 'react-native-screens';
234+
235+
<BottomSheetModal
236+
containerComponent={(props) => <FullWindowOverlay>{props.children}</FullWindowOverlay>}
237+
>
238+
```
239+
240+
### 6. Keyboard Handling
241+
242+
```jsx
243+
<BottomSheet
244+
snapPoints={snapPoints}
245+
enableDynamicSizing={false}
246+
keyboardBehavior="interactive" // 'extend' | 'fillParent' | 'interactive'
247+
keyboardBlurBehavior="restore" // reset sheet position when keyboard dismisses
248+
enableBlurKeyboardOnGesture={true} // dismiss keyboard on drag
249+
>
250+
<BottomSheetTextInput
251+
placeholder="Type here..."
252+
style={styles.input}
253+
/>
254+
</BottomSheet>
255+
```
256+
257+
| `keyboardBehavior` | Effect |
258+
|--------------------|--------|
259+
| `extend` | Sheet grows to accommodate keyboard |
260+
| `fillParent` | Sheet fills parent when keyboard appears |
261+
| `interactive` | Sheet follows keyboard position interactively |
262+
263+
> 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.
264+
265+
## Derived Animations with `animatedPosition`
266+
267+
Use the `animatedPosition` shared value for smooth derived UI that stays on the UI thread:
268+
269+
```jsx
270+
const animatedPosition = useSharedValue(0);
271+
272+
const backdropStyle = useAnimatedStyle(() => ({
273+
opacity: interpolate(
274+
animatedPosition.value,
275+
[0, 300],
276+
[0.5, 0],
277+
Extrapolation.CLAMP
278+
),
279+
}));
280+
281+
<BottomSheet animatedPosition={animatedPosition} snapPoints={snapPoints}>
282+
<Content />
283+
</BottomSheet>
284+
<Animated.View style={[StyleSheet.absoluteFill, backdropStyle]} pointerEvents="none" />
285+
```
286+
287+
## Native Alternative: react-native-true-sheet
288+
289+
If your app already runs on **New Architecture (Fabric)**, consider `@lodev09/react-native-true-sheet` — a fully native bottom sheet that sidesteps JS re-render problems entirely.
290+
291+
| Scenario | Recommendation |
292+
|----------|---------------|
293+
| Need deep JS customization (custom gestures, animated derived UI) | `@gorhom/bottom-sheet` |
294+
| Standard sheet with native feel + accessibility | `react-native-true-sheet` |
295+
| Legacy Architecture (no Fabric) | `@gorhom/bottom-sheet` (true-sheet v3+ requires Fabric) |
296+
| Web support needed | Either (true-sheet uses `@gorhom/bottom-sheet` on web internally) |
297+
298+
**Advantages**: zero JS overhead (sheet lives in native land — no SharedValue plumbing needed), built-in keyboard handling, native screen reader support, side sheet on tablets, iOS 26+ Liquid Glass support, React Navigation sheet navigator integration.
299+
300+
**Requirements**: New Architecture (Fabric) for v3+, use v2.x for Legacy Architecture.
301+
302+
```bash
303+
npm install @lodev09/react-native-true-sheet
304+
```
305+
306+
> If requirements are met and you don't need the fine-grained Reanimated-driven customization described in this skill, `react-native-true-sheet` is the simpler and more performant choice.
307+
308+
## Common Pitfalls
309+
310+
- **Using `onChange` for continuous position tracking** — it fires on snap completion only (discrete). Use `animatedPosition` or `animatedIndex` shared values instead.
311+
- **Forgetting `pointerEvents='none'` on always-mounted hidden elements** — invisible elements still capture touches.
312+
- **Missing accessibility attributes on hidden elements** — add `accessibilityElementsHidden` and `importantForAccessibility='no-hide-descendants'`.
313+
- **Bundling independent state values in one context** — see [js-atomic-state.md](./js-atomic-state.md) for splitting patterns.
314+
- **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.
315+
- **Using React Native `ScrollView`/`FlatList` inside bottom sheet** — gestures won't coordinate. Use `BottomSheetScrollView`, `BottomSheetFlatList`, etc.
316+
- **Using React Native touchables on Android** — import `TouchableOpacity`, `TouchableHighlight`, or `TouchableWithoutFeedback` from `@gorhom/bottom-sheet`.
317+
- **Not providing `containerHeight`** — causes an extra re-render on mount for measurement.
318+
- **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.
319+
320+
## Related Skills
321+
322+
- [js-animations-reanimated.md](./js-animations-reanimated.md) — SharedValue and useAnimatedStyle fundamentals
323+
- [js-atomic-state.md](./js-atomic-state.md) — Context splitting and atomic state patterns
324+
- [js-profile-react.md](./js-profile-react.md) — Profiling to measure re-render reduction
325+
- [js-measure-fps.md](./js-measure-fps.md) — Verify FPS improvement after optimization

skills/react-native-best-practices/references/js-measure-fps.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,4 +174,5 @@ flashlight compare baseline.json current.json
174174

175175
- [js-profile-react.md](./js-profile-react.md) - Find what's causing FPS drops
176176
- [js-animations-reanimated.md](./js-animations-reanimated.md) - Fix animation-related drops
177+
- [js-bottomsheet.md](./js-bottomsheet.md) - Measure bottom sheet gesture and snap performance
177178
- [js-lists-flatlist-flashlist.md](./js-lists-flatlist-flashlist.md) - Fix scroll-related drops

skills/react-native-best-practices/references/js-profile-react.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,5 @@ const Button = memo(({onPress, title}) => (
158158

159159
- [js-react-compiler.md](./js-react-compiler.md) - Automatic memoization
160160
- [js-atomic-state.md](./js-atomic-state.md) - Reduce re-renders with Jotai/Zustand
161+
- [js-bottomsheet.md](./js-bottomsheet.md) - Profile bottom sheet callback-driven re-renders
161162
- [js-measure-fps.md](./js-measure-fps.md) - Quantify frame rate impact

0 commit comments

Comments
 (0)