Reanimated
Gestures
Typescript
Animations
react native
performance
FlatList
Gesture Handler
The difference between a mobile app that feels native and one that seems like a web app wrapped in a container lies in the details: lists that scroll smoothly without stuttering, animations that respond instantly to user touches, and gestures that feel natural like in Instagram or Twitter. In React Native, achieving this level of polish requires understanding how rendering works under the hood and using the right tools.
The fundamental problem is that JavaScript runs in a separate thread from the native UI thread. When you scroll through a list, if the JavaScript thread is busy calculating which items to show or updating states, the interface freezes. When you animate an element with traditional Animated, each frame needs to communicate between threads through the bridge, causing frame drops. And when the user drags something with PanResponder, the latency between the gesture and visual response is noticeable.
Before diving into the code, we need to configure three fundamental libraries that will transform your app. Each one solves a specific performance and user experience problem.
1npm install react-native-reanimated react-native-gesture-handler @shopify/flash-list 2 3cd ios && pod install && cd ..
Once installed, you need to configure them correctly. Unlike pure JavaScript libraries, these work directly with native code and require modifications to the project configuration.
Reanimated needs a Babel plugin that transforms your JavaScript code into worklets, functions that can run directly in the native UI thread without going through the bridge. This is what enables smooth animations even when the JavaScript thread is busy.
Modify your babel.config.js:
1module.exports = { 2 presets: ['module:metro-react-native-babel-preset'], 3 plugins: [ 4 'react-native-reanimated/plugin', // Must always be the last plugin 5 ], 6};
It's crucial that this plugin is at the end because it needs to process the code after all other transformations. If you place it before other plugins, animations won't work correctly.
Gesture Handler intercepts touch events before React Native processes them, enabling instant responses. For it to work, your component tree must be wrapped in a special context.
In App.tsx:
1import { GestureHandlerRootView } from 'react-native-gesture-handler'; 2 3export default function App() { 4 return ( 5 <GestureHandlerRootView style={{ flex: 1 }}> 6 {/* Your app here */} 7 </GestureHandlerRootView> 8 ); 9}
This component configures the native gesture system for your entire app. Without it, gestures simply won't work.
For Android, you also need to modify MainActivity.java (or .kt if using Kotlin):
1import com.facebook.react.ReactActivityDelegate; 2import com.facebook.react.defaults.DefaultReactActivityDelegate; 3 4@Override 5protected ReactActivityDelegate createReactActivityDelegate() { 6 return new DefaultReactActivityDelegate( 7 this, 8 getMainComponentName(), 9 DefaultNewArchitectureEntryPoint.getFabricEnabled() 10 ); 11}
After these changes, it's essential to do a complete rebuild of your app. Native code changes aren't reflected with hot reload.
React Native's FlatList component seems simple, but when you use it with default configuration in large lists, you'll see lag, choppy scrolling, and excessive memory consumption. The problem is that FlatList tries to be smart by dynamically measuring the size of each item, which is expensive. We need to give it explicit information so it stops guessing.
Imagine a list of 10,000 contacts. Without getItemLayout, when the user scrolls quickly to the end, FlatList needs to calculate the exact position by mentally measuring each of the previous items. It's like trying to find page 500 of a book without page numbers. With getItemLayout, you give FlatList a simple mathematical formula to calculate any position instantly.
1interface Contact { 2 id: string; 3 name: string; 4 email: string; 5 phone: string; 6} 7 8const ITEM_HEIGHT = 80; 9const SEPARATOR_HEIGHT = 1; 10 11const ContactList: React.FC = () => { 12 const [contacts, setContacts] = useState<Contact[]>([]); 13 14 const renderItem = ({ item }: { item: Contact }) => ( 15 <ContactCard contact={item} /> 16 ); 17 18 const getItemLayout = ( 19 data: Contact[] | null | undefined, 20 index: number 21 ) => ({ 22 length: ITEM_HEIGHT, 23 offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index, 24 index, 25 }); 26 27 return ( 28 <FlatList 29 data={contacts} 30 renderItem={renderItem} 31 getItemLayout={getItemLayout} 32 keyExtractor={item => item.id} 33 /> 34 ); 35};
The offset is the Y position where each item begins. If each item measures 80px and has a 1px separator, item 0 is at position 0, item 1 at position 81, item 2 at position 162, and so on. This simple multiplication prevents FlatList from having to render and measure off-screen items to calculate positions. The result is instant scrolling even with tens of thousands of items.
Of course, this only works if your items have fixed or predictable height. If each item has variable height (like posts with different text lengths), you can't use getItemLayout and will need other optimizations.
By default, FlatList keeps 21 "screens" of items rendered: 10 above, the current one, and 10 below. This consumes memory unnecessarily. The trick is finding the perfect balance where the list feels fluid but you don't load data the user will probably never see.
1interface Product { 2 id: string; 3 name: string; 4 price: number; 5 imageUrl: string; 6} 7 8const ProductList: React.FC<{ products: Product[] }> = ({ products }) => ( 9 <FlatList 10 data={products} 11 renderItem={({ item }) => <ProductCard product={item} />} 12 windowSize={5} 13 maxToRenderPerBatch={10} 14 updateCellsBatchingPeriod={50} 15 initialNumToRender={10} 16 /> 17);
The windowSize of 5 means we keep 2 screens above, the current screen, and 2 screens below. This drastically reduces memory usage without sacrificing experience. maxToRenderPerBatch controls how many items are processed per frame during scrolling, a low number (like 10) prevents the JavaScript thread from getting saturated. updateCellsBatchingPeriod of 50ms sets the batch update frequency, giving the thread time to breathe between renders. Finally, initialNumToRender determines how many items to show immediately on initial mount, avoiding the dreaded blank screen while data loads.
This is an Android-specific optimization that literally removes native views that are off-screen from the view tree. On iOS, the system already does this automatically, but on Android you need to activate it explicitly.
1interface Message { 2 id: string; 3 text: string; 4 sender: string; 5 timestamp: number; 6} 7 8const MESSAGE_HEIGHT = 60; 9 10const ChatMessages: React.FC<{ messages: Message[] }> = ({ messages }) => ( 11 <FlatList 12 data={messages} 13 renderItem={({ item }) => <MessageBubble message={item} />} 14 removeClippedSubviews={Platform.OS === 'android'} 15 windowSize={7} 16 getItemLayout={(data, index) => ({ 17 length: MESSAGE_HEIGHT, 18 offset: MESSAGE_HEIGHT * index, 19 index, 20 })} 21 /> 22);
The important warning is that removeClippedSubviews can cause visual bugs if your items have complex layouts with absolutely positioned elements or animations. Test it extensively before leaving it in production.
Shopify built FlashList after facing performance problems in their app with thousands of products. The fundamental difference lies in how it recycles views. While FlatList constantly destroys and recreates components, FlashList aggressively reuses existing components, only changing their props. It's like having actors change costumes instead of hiring new actors for each scene.
1import { FlashList } from "@shopify/flash-list"; 2 3interface InventoryItem { 4 sku: string; 5 name: string; 6 stock: number; 7 warehouse: string; 8} 9 10const InventoryList: React.FC<{ items: InventoryItem[] }> = ({ items }) => ( 11 <FlashList 12 data={items} 13 renderItem={({ item }) => <InventoryCard item={item} />} 14 estimatedItemSize={120} 15 keyExtractor={item => item.sku} 16 /> 17);
The estimatedItemSize is crucial for FlashList to calculate how many items to prepare. It doesn't need to be exact, but the closer to the real average, the better the performance will be. FlashList works especially well with heterogeneous lists where items have variable heights, something where FlatList traditionally struggles.
The question is when to use FlashList over FlatList. If your list has more than 100 items, especially if they have variable heights or images, FlashList will probably give you better performance. The only trade-off is that you add an extra dependency to your project.
The problem with traditional animations in React Native is that each frame needs communication between the JavaScript thread and the UI thread. If your JavaScript is busy processing something, the animation freezes. Reanimated 3 solves this by running animations completely in the native UI thread using "worklets", special JavaScript functions that are serialized and sent to the native side once.
1import Animated, { 2 useSharedValue, 3 useAnimatedStyle, 4 withSpring, 5} from 'react-native-reanimated'; 6 7const PulsingHeart: React.FC = () => { 8 const scale = useSharedValue(1); 9 const [liked, setLiked] = useState(false); 10 11 const animatedStyle = useAnimatedStyle(() => ({ 12 transform: [{ scale: scale.value }], 13 })); 14 15 const onPress = () => { 16 scale.value = withSpring(liked ? 1 : 1.3, { 17 damping: 10, 18 stiffness: 100, 19 }); 20 setLiked(!liked); 21 }; 22 23 return ( 24 <TouchableOpacity onPress={onPress}> 25 <Animated.View style={animatedStyle}> 26 <Heart color={liked ? 'red' : 'gray'} /> 27 </Animated.View> 28 </TouchableOpacity> 29 ); 30};
The useAnimatedStyle is a worklet, notice how the function inside accesses scale.value. This code doesn't run in JavaScript, it runs in the native UI thread. The withSpring is also a worklet that calculates spring physics directly on the native side. The result is an animation that runs at 60 FPS even if the JavaScript thread is completely blocked processing data.
The withSpring parameters control the animation physics. Low damping (like 10) creates more bounce, while high stiffness (like 100) makes the animation more "tense" and fast. Experiment with these values until you find the feeling you're looking for.
Layout animations are perhaps the most impressive feature of Reanimated 3. Instead of manually calculating the initial and final positions of an element, you simply declare what type of animation you want and Reanimated handles the rest. It's especially powerful for lists where items constantly appear and disappear.
1import Animated, { 2 FadeIn, 3 FadeOut, 4 SlideInRight, 5 SlideOutLeft, 6 Layout, 7} from 'react-native-reanimated'; 8 9interface Task { 10 id: string; 11 title: string; 12 completed: boolean; 13} 14 15const TaskList: React.FC<{ tasks: Task[] }> = ({ tasks }) => ( 16 <View> 17 {tasks.map((task, index) => ( 18 <Animated.View 19 key={task.id} 20 entering={SlideInRight.delay(index * 50).duration(300)} 21 exiting={SlideOutLeft.duration(200)} 22 layout={Layout.springify()} 23 > 24 <TaskCard task={task} /> 25 </Animated.View> 26 ))} 27 </View> 28);
When a new task appears in the array, it automatically slides in from the right with a small delay based on its index, creating a cascade effect. When removed, it exits to the left. And most impressively: when other items change position (because one was eliminated), the layout={Layout.springify()} automatically animates the repositioning with spring physics. All this without a single line of manual coordinate calculation.
The .delay() and .duration() are chainable methods that customize the animation. You can combine multiple modifiers to create complex effects declaratively.
Interpolations convert one range of values to another range of values smoothly. They're perfect for creating effects where multiple visual properties change in sync based on a single source of truth, like scroll position.
1import { 2 interpolate, 3 Extrapolate, 4 useAnimatedScrollHandler 5} from 'react-native-reanimated'; 6 7const HEADER_MAX_HEIGHT = 300; 8const HEADER_MIN_HEIGHT = 100; 9 10const ParallaxHeader: React.FC = () => { 11 const scrollY = useSharedValue(0); 12 13 const headerStyle = useAnimatedStyle(() => { 14 const height = interpolate( 15 scrollY.value, 16 [0, 200], 17 [HEADER_MAX_HEIGHT, HEADER_MIN_HEIGHT], 18 Extrapolate.CLAMP 19 ); 20 21 const opacity = interpolate( 22 scrollY.value, 23 [0, 100, 200], 24 [1, 0.7, 0], 25 Extrapolate.CLAMP 26 ); 27 28 return { height, opacity }; 29 }); 30 31 const scrollHandler = useAnimatedScrollHandler({ 32 onScroll: (event) => { 33 scrollY.value = event.contentOffset.y; 34 }, 35 }); 36 37 return ( 38 <> 39 <Animated.View style={[styles.header, headerStyle]}> 40 <Image source={headerImage} style={styles.headerImage} /> 41 </Animated.View> 42 43 <Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16}> 44 <Content /> 45 </Animated.ScrollView> 46 </> 47 ); 48};
Here the magic is in the interpolation. When scrollY goes from 0 to 200, the header height goes from 300 to 100 pixels proportionally. Simultaneously, opacity goes from completely visible (1) to invisible (0), but with an intermediate point at scrollY=100 where it has 0.7 opacity. Extrapolate.CLAMP ensures values don't go outside the defined ranges if the user over-scrolls.
The scrollEventThrottle={16} is important because it determines how many milliseconds between scroll event reports. 16ms equals approximately 60 FPS, the perfect balance between precision and performance.
Gestures in mobile applications must feel instant and natural. The problem with React Native's PanResponder is that it processes events in the JavaScript thread, introducing noticeable latency. Gesture Handler v2 processes touches directly in the native thread and only notifies JavaScript when absolutely necessary.
A pan (drag) gesture seems simple but involves several states: touch start, continuous movement, and release with potential inertia. Gesture Handler handles all this complexity elegantly.
1import { GestureDetector, Gesture } from 'react-native-gesture-handler'; 2import Animated, { 3 useSharedValue, 4 useAnimatedStyle, 5 withDecay, 6} from 'react-native-reanimated'; 7 8const DraggableCard: React.FC = () => { 9 const translateX = useSharedValue(0); 10 const translateY = useSharedValue(0); 11 const context = useSharedValue({ x: 0, y: 0 }); 12 13 const panGesture = Gesture.Pan() 14 .onStart(() => { 15 context.value = { 16 x: translateX.value, 17 y: translateY.value, 18 }; 19 }) 20 .onUpdate((event) => { 21 translateX.value = event.translationX + context.value.x; 22 translateY.value = event.translationY + context.value.y; 23 }) 24 .onEnd((event) => { 25 translateX.value = withDecay({ 26 velocity: event.velocityX, 27 clamp: [-200, 200], 28 }); 29 translateY.value = withDecay({ 30 velocity: event.velocityY, 31 clamp: [-400, 400], 32 }); 33 }); 34 35 const animatedStyle = useAnimatedStyle(() => ({ 36 transform: [ 37 { translateX: translateX.value }, 38 { translateY: translateY.value }, 39 ], 40 })); 41 42 return ( 43 <GestureDetector gesture={panGesture}> 44 <Animated.View style={[styles.card, animatedStyle]}> 45 <Text>Drag me!</Text> 46 </Animated.View> 47 </GestureDetector> 48 ); 49};
The key pattern here is using context to save the position where dragging started. Without this, every time you release and drag again, the element would jump to its original position. The onUpdate calculates the new position by adding how much the finger moved (event.translationX) to where the element was when the gesture started (context.value.x).
The inertia physics comes with withDecay in onEnd. It takes the finger velocity when released and continues the movement naturally decelerating, exactly like in native apps. The clamp prevents the element from going outside defined bounds, in this case keeping it within a range of -200 to 200 pixels horizontally and -400 to 400 vertically.
Swipe actions are that pattern where you swipe a list element to reveal options like delete or archive. It's ubiquitous in email apps, to-do lists, and social networks because it's extremely efficient for quick actions.
1import { runOnJS } from 'react-native-reanimated'; 2 3interface SwipeableRowProps { 4 item: { id: string; title: string }; 5 onDelete: (id: string) => void; 6 onArchive: (id: string) => void; 7} 8 9const SwipeableRow: React.FC<SwipeableRowProps> = ({ 10 item, 11 onDelete, 12 onArchive 13}) => { 14 const translateX = useSharedValue(0); 15 const [isRemoving, setIsRemoving] = useState(false); 16 17 const panGesture = Gesture.Pan() 18 .activeOffsetX([-10, 10]) 19 .onUpdate((event) => { 20 translateX.value = Math.max(-150, Math.min(150, event.translationX)); 21 }) 22 .onEnd(() => { 23 const shouldDelete = translateX.value < -100; 24 const shouldArchive = translateX.value > 100; 25 26 if (shouldDelete) { 27 translateX.value = withSpring(-300, {}, () => { 28 runOnJS(setIsRemoving)(true); 29 runOnJS(onDelete)(item.id); 30 }); 31 } else if (shouldArchive) { 32 translateX.value = withSpring(300, {}, () => { 33 runOnJS(onArchive)(item.id); 34 }); 35 } else { 36 translateX.value = withSpring(0); 37 } 38 }); 39 40 const animatedStyle = useAnimatedStyle(() => ({ 41 transform: [{ translateX: translateX.value }], 42 })); 43 44 if (isRemoving) return null; 45 46 return ( 47 <View style={styles.container}> 48 <GestureDetector gesture={panGesture}> 49 <Animated.View style={[styles.row, animatedStyle]}> 50 <Text>{item.title}</Text> 51 </Animated.View> 52 </GestureDetector> 53 </View> 54 ); 55};
The .activeOffsetX([-10, 10]) is an important detail: it prevents the swipe from activating with accidental vertical movements. The gesture only "activates" when the user moves at least 10 pixels horizontally. This allows the list's vertical scroll to work without conflict.
The Math.max(-150, Math.min(150, ...)) limits the swipe so you can't drag infinitely. And the logic in onEnd determines the user's intention: if they passed the -100 pixel threshold, we want to delete; if they passed +100, archive; otherwise, return to original position.
The runOnJS is crucial because we're in a worklet (UI thread) but need to execute JavaScript functions like setIsRemoving and onDelete. This helper explicitly marks that those calls should be made in the JavaScript thread.
Long press is perfect for revealing secondary actions without cluttering the UI with buttons. The common pattern is to reduce the element's scale and opacity to give visual feedback that the gesture was recognized.
1interface LongPressCardProps { 2 item: { id: string; title: string }; 3 onLongPress: (item: any) => void; 4} 5 6const LongPressCard: React.FC<LongPressCardProps> = ({ item, onLongPress }) => { 7 const scale = useSharedValue(1); 8 const opacity = useSharedValue(1); 9 10 const longPressGesture = Gesture.LongPress() 11 .minDuration(400) 12 .onStart(() => { 13 scale.value = withTiming(0.95, { duration: 200 }); 14 opacity.value = withTiming(0.7, { duration: 200 }); 15 }) 16 .onEnd(() => { 17 scale.value = withSpring(1); 18 opacity.value = withSpring(1); 19 runOnJS(onLongPress)(item); 20 }) 21 .onFinalize(() => { 22 scale.value = withSpring(1); 23 opacity.value = withSpring(1); 24 }); 25 26 const animatedStyle = useAnimatedStyle(() => ({ 27 transform: [{ scale: scale.value }], 28 opacity: opacity.value, 29 })); 30 31 return ( 32 <GestureDetector gesture={longPressGesture}> 33 <Animated.View style={[styles.card, animatedStyle]}> 34 <Text>{item.title}</Text> 35 </Animated.View> 36 </GestureDetector> 37 ); 38};
The .minDuration(400) defines that the user must hold pressed for at least 400ms before the gesture activates. This prevents accidental activations. The onStart executes when the long press is recognized (after 400ms), visually reducing the element to give feedback. The onEnd executes when the user releases, at which point we restore the appearance and execute the action. The onFinalize is a safety net that executes regardless of how the gesture ended (cancellation, success, error), ensuring the element always returns to its normal state.
One of the most powerful capabilities of Gesture Handler v2 is composing multiple gestures. You can define whether they execute simultaneously, exclusively (only one at a time), or in sequence.
1const ZoomablePannable: React.FC<{ children: React.ReactNode }> = ({ 2 children 3}) => { 4 const scale = useSharedValue(1); 5 const translateX = useSharedValue(0); 6 const translateY = useSharedValue(0); 7 const savedScale = useSharedValue(1); 8 const savedTranslate = useSharedValue({ x: 0, y: 0 }); 9 10 const pinchGesture = Gesture.Pinch() 11 .onUpdate((event) => { 12 scale.value = savedScale.value * event.scale; 13 }) 14 .onEnd(() => { 15 savedScale.value = scale.value; 16 }); 17 18 const panGesture = Gesture.Pan() 19 .onUpdate((event) => { 20 translateX.value = savedTranslate.value.x + event.translationX; 21 translateY.value = savedTranslate.value.y + event.translationY; 22 }) 23 .onEnd(() => { 24 savedTranslate.value = { 25 x: translateX.value, 26 y: translateY.value, 27 }; 28 }); 29 30 const composed = Gesture.Simultaneous(pinchGesture, panGesture); 31 32 const animatedStyle = useAnimatedStyle(() => ({ 33 transform: [ 34 { translateX: translateX.value }, 35 { translateY: translateY.value }, 36 { scale: scale.value }, 37 ], 38 })); 39 40 return ( 41 <GestureDetector gesture={composed}> 42 <Animated.View style={animatedStyle}> 43 {children} 44 </Animated.View> 45 </GestureDetector> 46 ); 47};
The Gesture.Simultaneous allows both gestures to occur at the same time. You can pinch to zoom while dragging the image, exactly like in iOS Photos app. If you used Gesture.Exclusive, only one gesture could be active at a time, forcing the user to finish one before starting the other.
The pattern of saving values in savedScale and savedTranslate ensures each gesture continues from where the previous one ended, instead of resetting to default values.
Now that we understand each piece individually, let's see how they integrate in a real production component. An animated feed with swipe actions and double-tap to like combines FlashList for performance, Reanimated for smooth animations, and Gesture Handler for natural interactions.
1import { FlashList } from "@shopify/flash-list"; 2import { withSequence } from 'react-native-reanimated'; 3 4interface Post { 5 id: string; 6 title: string; 7 content: string; 8 imageUrl?: string; 9} 10 11const AnimatedFeed: React.FC = () => { 12 const [posts, setPosts] = useState<Post[]>([]); 13 14 const renderItem = useCallback( 15 ({ item, index }: { item: Post; index: number }) => ( 16 <Animated.View 17 entering={FadeIn.delay(index * 50)} 18 exiting={FadeOut} 19 > 20 <SwipeablePostCard 21 post={item} 22 onDelete={(id) => setPosts(p => p.filter(post => post.id !== id))} 23 onLike={(id) => console.log('Liked:', id)} 24 /> 25 </Animated.View> 26 ), 27 [] 28 ); 29 30 return ( 31 <FlashList 32 data={posts} 33 renderItem={renderItem} 34 estimatedItemSize={200} 35 keyExtractor={item => item.id} 36 /> 37 ); 38};
Each post enters with a staggered fade based on its index, creating that cascade effect seen in premium apps. When deleted, it exits with fade out. FlashList efficiently handles component recycling while the user scrolls.
1interface SwipeablePostCardProps { 2 post: Post; 3 onDelete: (id: string) => void; 4 onLike: (id: string) => void; 5} 6 7const SwipeablePostCard: React.FC<SwipeablePostCardProps> = ({ 8 post, 9 onDelete, 10 onLike, 11}) => { 12 const translateX = useSharedValue(0); 13 const scale = useSharedValue(1); 14 15 const swipeGesture = Gesture.Pan() 16 .activeOffsetX([-10, 10]) 17 .onUpdate((event) => { 18 translateX.value = Math.min(0, event.translationX); 19 }) 20 .onEnd(() => { 21 if (translateX.value < -100) { 22 translateX.value = withSpring(-400, {}, () => { 23 runOnJS(onDelete)(post.id); 24 }); 25 } else { 26 translateX.value = withSpring(0); 27 } 28 }); 29 30 const doubleTap = Gesture.Tap() 31 .numberOfTaps(2) 32 .onEnd(() => { 33 scale.value = withSequence( 34 withSpring(1.2), 35 withSpring(1) 36 ); 37 runOnJS(onLike)(post.id); 38 }); 39 40 const composed = Gesture.Exclusive(swipeGesture, doubleTap); 41 42 const animatedStyle = useAnimatedStyle(() => ({ 43 transform: [ 44 { translateX: translateX.value }, 45 { scale: scale.value }, 46 ], 47 })); 48 49 return ( 50 <GestureDetector gesture={composed}> 51 <Animated.View style={[styles.postCard, animatedStyle]}> 52 <PostContent post={post} /> 53 </Animated.View> 54 </GestureDetector> 55 ); 56};
Here we see gesture composition in action. The Gesture.Exclusive ensures that swipe and double-tap don't conflict. If it detects a double-tap, the swipe doesn't activate, and vice versa. The withSequence in the double-tap creates that satisfying "pop" effect where the element briefly grows and then returns to size, giving immediate feedback of the action.
The Math.min(0, event.translationX) restriction ensures you can only swipe left (negative values), preventing right swipes that could confuse the user. When the swipe exceeds -100 pixels, we interpret that the user wants to delete the post and animate it completely off-screen before executing the actual deletion.
Now that you master the tools, let's talk about common mistakes that can ruin performance even with the best libraries.
The renderItem of FlatList or FlashList executes every time an item enters the screen. If you don't use useCallback, React recreates the function on every render of the parent component, causing all items to re-render unnecessarily.
1// ❌ Bad: renderItem is recreated on every render 2const BadList: React.FC<{ items: Item[] }> = ({ items }) => { 3 return ( 4 <FlashList 5 data={items} 6 renderItem={({ item }) => <ItemCard item={item} />} 7 estimatedItemSize={100} 8 /> 9 ); 10}; 11 12// ✅ Good: renderItem is stable 13const GoodList: React.FC<{ items: Item[] }> = ({ items }) => { 14 const renderItem = useCallback( 15 ({ item }: { item: Item }) => <ItemCard item={item} />, 16 [] 17 ); 18 19 return ( 20 <FlashList 21 data={items} 22 renderItem={renderItem} 23 estimatedItemSize={100} 24 /> 25 ); 26};
The same applies to keyExtractor, getItemLayout, and any other function you pass to the list. Memoization with useCallback ensures React can make reconciliation optimizations correctly.
When using layout animations with lists, keys are absolutely critical. React uses keys to determine which elements are the same between renders. If your keys change or aren't unique, animations will break or elements will do strange things.
1// ❌ Bad: index-based keys 2{items.map((item, index) => ( 3 <Animated.View key={index} entering={FadeIn}> 4 <ItemCard item={item} /> 5 </Animated.View> 6))} 7 8// ✅ Good: unique and stable keys 9{items.map((item) => ( 10 <Animated.View key={item.id} entering={FadeIn}> 11 <ItemCard item={item} /> 12 </Animated.View> 13))}
The problem with indices as keys is that when you delete an element, all subsequent indices change. React thinks they're completely different elements and destroys/recreates components instead of animating them correctly.
1const ProperCleanup: React.FC = () => { 2 const translateX = useSharedValue(0); 3 4 useEffect(() => { 5 return () => { 6 // Cancel animations in progress 7 cancelAnimation(translateX); 8 }; 9 }, [translateX]); 10 11 // Rest of component... 12};
Although Reanimated generally handles cleanup automatically, in cases where you manually control the lifecycle of animations (like with cancelAnimation or runOnUI), explicit cleanup prevents subtle problems.
Creating mobile interfaces that feel truly native in React Native requires deeply understanding how threads work and using the right tools for each problem. FlatList and FlashList solve efficient rendering of massive lists by eliminating unnecessary measurements and aggressively recycling views. Reanimated 3 brings animations to the native UI thread where they can run at 60 FPS independently of what JavaScript is doing. Gesture Handler v2 processes touches with almost zero latency, creating interactions that feel instant.
The key is being intentional with each optimization. Not all lists need FlashList nor all animations need Reanimated. But when your users start noticing lag or when your app feels less fluid than native alternatives, these tools with TypeScript give you the necessary control to compete with any pure native experience. The result is an application that not only works well, but feels premium from the first touch.