Skip to main content

Module 5: Lists and Performance

FlatList Features

Interactive capabilities that bring your lists to life

🎯 Learning Objectives

  • Implement pull-to-refresh for data refreshing
  • Build infinite scroll with onEndReached
  • Use scroll methods to navigate programmatically
  • Handle scroll events for custom behaviors
  • Create multi-column grid layouts
  • Build inverted lists for chat interfaces
  • Implement sticky headers and scroll indicators

Pull-to-Refresh

Pull-to-refresh is a standard mobile pattern that lets users refresh content by pulling down on the list. FlatList has built-in support for this interaction.

Basic Implementation

import React, { useState, useCallback } from 'react';
import { FlatList, View, Text, StyleSheet } from 'react-native';

function RefreshableList() {
  const [data, setData] = useState(initialData);
  const [refreshing, setRefreshing] = useState(false);

  const onRefresh = useCallback(async () => {
    setRefreshing(true);
    
    try {
      const newData = await fetchLatestData();
      setData(newData);
    } catch (error) {
      console.error('Refresh failed:', error);
    } finally {
      setRefreshing(false);
    }
  }, []);

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      // Pull-to-refresh props
      refreshing={refreshing}
      onRefresh={onRefresh}
    />
  );
}

πŸ“– The Two Required Props

refreshing: Boolean indicating if refresh is in progress. Controls the spinner visibility.

onRefresh: Function called when user pulls to refresh. Set refreshing=true at start, false when done.

Custom Refresh Control

For more control over the refresh indicator, use the RefreshControl component:

import { FlatList, RefreshControl } from 'react-native';

function CustomRefreshList() {
  const [refreshing, setRefreshing] = useState(false);

  const onRefresh = useCallback(async () => {
    setRefreshing(true);
    await fetchData();
    setRefreshing(false);
  }, []);

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      refreshControl={
        <RefreshControl
          refreshing={refreshing}
          onRefresh={onRefresh}
          // Customization options
          tintColor="#6200ee"           // iOS spinner color
          colors={['#6200ee', '#03dac6']} // Android spinner colors
          progressBackgroundColor="#fff" // Android background
          title="Pull to refresh..."    // iOS title text
          titleColor="#666"             // iOS title color
          progressViewOffset={50}       // Android offset
        />
      }
    />
  );
}
Idle Pull ↓ Pulling Release Refreshing Done Updated

Handling Refresh with React Query / TanStack Query

import { useQuery } from '@tanstack/react-query';

function QueryRefreshList() {
  const { data, isLoading, isRefetching, refetch } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  return (
    <FlatList
      data={data ?? []}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      refreshing={isRefetching}
      onRefresh={refetch}
      ListEmptyComponent={isLoading ? <LoadingSpinner /> : <EmptyState />}
    />
  );
}

⚠️ Common Mistakes

  • Forgetting to set refreshing=false: Spinner stays forever if you don't reset it
  • Not handling errors: Always wrap in try/catch with finally for the state reset
  • Mutating data: Replace the array, don't push to it

Infinite Scroll

Infinite scroll loads more data as the user approaches the end of the list. This is essential for feeds, search results, and any large dataset.

Basic Implementation

import React, { useState, useCallback } from 'react';
import { FlatList, ActivityIndicator, View } from 'react-native';

function InfiniteScrollList() {
  const [data, setData] = useState<Item[]>(initialData);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [page, setPage] = useState(1);

  const loadMore = useCallback(async () => {
    // Prevent multiple simultaneous calls
    if (loading || !hasMore) return;
    
    setLoading(true);
    
    try {
      const nextPage = page + 1;
      const newItems = await fetchPage(nextPage);
      
      if (newItems.length === 0) {
        setHasMore(false);
      } else {
        setData(prev => [...prev, ...newItems]);
        setPage(nextPage);
      }
    } catch (error) {
      console.error('Failed to load more:', error);
    } finally {
      setLoading(false);
    }
  }, [loading, hasMore, page]);

  const renderFooter = () => {
    if (!loading) return null;
    return (
      <View style={styles.footer}>
        <ActivityIndicator size="small" />
      </View>
    );
  };

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
      ListFooterComponent={renderFooter}
    />
  );
}

Understanding onEndReachedThreshold

The onEndReachedThreshold determines how close to the end the user must scroll before onEndReached fires:

onEndReachedThreshold Values Content 10% 0.1 (Late trigger) Content 50% 0.5 (Recommended) Trigger zone 100% 1.0 (Early trigger) Trigger zone 200% 2.0 (Very early) Visible Trigger zone
// onEndReachedThreshold values:
// 0.1 = Trigger when 10% of visible height from bottom (late)
// 0.5 = Trigger when 50% of visible height from bottom (recommended)
// 1.0 = Trigger when 1 screen away from bottom
// 2.0 = Trigger when 2 screens away from bottom (very early)

<FlatList
  onEndReached={loadMore}
  onEndReachedThreshold={0.5}  // Trigger at half-screen from bottom
/>

Preventing Double Loads

onEndReached can fire multiple times during a scroll. Prevent duplicate API calls:

function RobustInfiniteList() {
  const [data, setData] = useState<Item[]>([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  
  // Use ref to track loading state (avoids stale closure issues)
  const isLoadingRef = useRef(false);

  const loadMore = useCallback(async () => {
    // Guard against multiple calls
    if (isLoadingRef.current || !hasMore) {
      return;
    }
    
    isLoadingRef.current = true;
    
    try {
      const response = await api.fetchItems({ page: page + 1 });
      
      setData(prev => [...prev, ...response.items]);
      setPage(prev => prev + 1);
      setHasMore(response.hasNextPage);
    } catch (error) {
      // Handle error - maybe show a "tap to retry" in footer
    } finally {
      isLoadingRef.current = false;
    }
  }, [page, hasMore]);

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
    />
  );
}

Complete Infinite Scroll Example

import React, { useState, useCallback, useRef } from 'react';
import { 
  FlatList, 
  View, 
  Text, 
  ActivityIndicator, 
  Pressable,
  StyleSheet 
} from 'react-native';

interface Post {
  id: string;
  title: string;
  body: string;
}

interface ApiResponse {
  posts: Post[];
  nextPage: number | null;
}

export default function InfiniteFeed() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [nextPage, setNextPage] = useState<number | null>(1);
  const [error, setError] = useState<string | null>(null);
  
  const isLoading = useRef(false);

  const fetchPosts = useCallback(async (page: number): Promise<ApiResponse> => {
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    const newPosts = Array.from({ length: 10 }, (_, i) => ({
      id: `post-${page}-${i}`,
      title: `Post ${(page - 1) * 10 + i + 1}`,
      body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
    }));
    
    return {
      posts: newPosts,
      nextPage: page < 5 ? page + 1 : null, // Simulate 5 pages
    };
  }, []);

  const loadMore = useCallback(async () => {
    if (isLoading.current || nextPage === null) return;
    
    isLoading.current = true;
    setError(null);
    
    try {
      const response = await fetchPosts(nextPage);
      setPosts(prev => [...prev, ...response.posts]);
      setNextPage(response.nextPage);
    } catch (err) {
      setError('Failed to load posts');
    } finally {
      isLoading.current = false;
    }
  }, [nextPage, fetchPosts]);

  // Load initial data
  React.useEffect(() => {
    loadMore();
  }, []);

  const renderItem = useCallback(({ item }: { item: Post }) => (
    <View style={styles.postCard}>
      <Text style={styles.postTitle}>{item.title}</Text>
      <Text style={styles.postBody}>{item.body}</Text>
    </View>
  ), []);

  const renderFooter = () => {
    if (error) {
      return (
        <Pressable style={styles.errorFooter} onPress={loadMore}>
          <Text style={styles.errorText}>{error}</Text>
          <Text style={styles.retryText}>Tap to retry</Text>
        </Pressable>
      );
    }
    
    if (nextPage === null) {
      return (
        <View style={styles.endFooter}>
          <Text style={styles.endText}>You've reached the end!</Text>
        </View>
      );
    }
    
    return (
      <View style={styles.loadingFooter}>
        <ActivityIndicator size="small" color="#6200ee" />
      </View>
    );
  };

  return (
    <FlatList
      data={posts}
      renderItem={renderItem}
      keyExtractor={(item) => item.id}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
      ListFooterComponent={renderFooter}
      contentContainerStyle={styles.listContent}
    />
  );
}

const styles = StyleSheet.create({
  listContent: {
    padding: 16,
  },
  postCard: {
    backgroundColor: '#fff',
    borderRadius: 8,
    padding: 16,
    marginBottom: 12,
    elevation: 2,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
  },
  postTitle: {
    fontSize: 16,
    fontWeight: '600',
    marginBottom: 8,
  },
  postBody: {
    fontSize: 14,
    color: '#666',
    lineHeight: 20,
  },
  loadingFooter: {
    paddingVertical: 20,
    alignItems: 'center',
  },
  errorFooter: {
    paddingVertical: 20,
    alignItems: 'center',
  },
  errorText: {
    color: '#f44336',
    marginBottom: 4,
  },
  retryText: {
    color: '#6200ee',
    fontWeight: '600',
  },
  endFooter: {
    paddingVertical: 20,
    alignItems: 'center',
  },
  endText: {
    color: '#999',
  },
});
flowchart TD
    A["User scrolls"] --> B{"Near end?
threshold check"} B -->|"No"| A B -->|"Yes"| C{"Already loading?"} C -->|"Yes"| A C -->|"No"| D{"Has more data?"} D -->|"No"| E["Show 'End of list'"] D -->|"Yes"| F["Set loading=true"] F --> G["Fetch next page"] G --> H{"Success?"} H -->|"Yes"| I["Append data"] H -->|"No"| J["Show error"] I --> K["Set loading=false"] J --> K K --> A style F fill:#fff3cd style I fill:#c8e6c9 style J fill:#ffcdd2

Scroll Methods

FlatList provides methods to programmatically scroll to specific positions. These require a ref to the FlatList component.

Setting Up the Ref

import { useRef } from 'react';
import { FlatList } from 'react-native';

function ScrollableList() {
  const flatListRef = useRef<FlatList>(null);

  return (
    <FlatList
      ref={flatListRef}
      data={data}
      renderItem={renderItem}
    />
  );
}

scrollToIndex

Scroll to a specific item by its index:

// Basic usage
const scrollToItem = (index: number) => {
  flatListRef.current?.scrollToIndex({
    index,
    animated: true,
  });
};

// With positioning options
flatListRef.current?.scrollToIndex({
  index: 10,
  animated: true,
  viewPosition: 0,    // 0 = top, 0.5 = center, 1 = bottom
  viewOffset: 0,      // Additional offset in pixels
});

// ⚠️ scrollToIndex requires getItemLayout for accuracy!
// Without it, you may get errors or incorrect positions

<FlatList
  ref={flatListRef}
  data={data}
  renderItem={renderItem}
  getItemLayout={(data, index) => ({
    length: ITEM_HEIGHT,
    offset: ITEM_HEIGHT * index,
    index,
  })}
  // Handle failures when getItemLayout isn't provided
  onScrollToIndexFailed={(info) => {
    // Wait and retry
    setTimeout(() => {
      flatListRef.current?.scrollToIndex({
        index: info.index,
        animated: true,
      });
    }, 100);
  }}
/>

scrollToOffset

Scroll to a specific pixel offset:

// Scroll to top
const scrollToTop = () => {
  flatListRef.current?.scrollToOffset({
    offset: 0,
    animated: true,
  });
};

// Scroll to specific position
const scrollToPosition = (offset: number) => {
  flatListRef.current?.scrollToOffset({
    offset,
    animated: true,
  });
};

// Common pattern: "Scroll to top" button
function ListWithScrollToTop() {
  const flatListRef = useRef<FlatList>(null);
  const [showScrollTop, setShowScrollTop] = useState(false);

  const handleScroll = (event) => {
    const offsetY = event.nativeEvent.contentOffset.y;
    setShowScrollTop(offsetY > 500);
  };

  return (
    <View style={{ flex: 1 }}>
      <FlatList
        ref={flatListRef}
        data={data}
        renderItem={renderItem}
        onScroll={handleScroll}
        scrollEventThrottle={16}
      />
      
      {showScrollTop && (
        <Pressable
          style={styles.scrollTopButton}
          onPress={() => {
            flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
          }}
        >
          <Text>↑ Top</Text>
        </Pressable>
      )}
    </View>
  );
}

scrollToEnd

Scroll to the end of the list:

// Scroll to bottom
const scrollToEnd = () => {
  flatListRef.current?.scrollToEnd({
    animated: true,
  });
};

// Use case: New message in chat
const handleNewMessage = (message: Message) => {
  setMessages(prev => [...prev, message]);
  
  // Wait for render, then scroll
  setTimeout(() => {
    flatListRef.current?.scrollToEnd({ animated: true });
  }, 100);
};

scrollToItem

Scroll to a specific item object:

// Scroll to a specific item (not by index)
const scrollToItem = (item: Item) => {
  flatListRef.current?.scrollToItem({
    item,
    animated: true,
    viewPosition: 0.5,  // Center the item
  });
};

// Useful when you have the item but not the index
const highlightedItem = items.find(i => i.isHighlighted);
if (highlightedItem) {
  scrollToItem(highlightedItem);
}

βœ… Scroll Methods Summary

Method Use When Requires
scrollToIndex You know the index getItemLayout recommended
scrollToOffset You know the pixel position Nothing extra
scrollToEnd Jump to bottom Nothing extra
scrollToItem You have the item object getItemLayout recommended

Scroll Events

FlatList inherits from ScrollView, giving you access to scroll events for custom behaviors like hiding headers, parallax effects, or scroll-based animations.

onScroll

import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';

function ScrollTrackingList() {
  const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
    const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
    
    // Current scroll position
    const scrollY = contentOffset.y;
    const scrollX = contentOffset.x;
    
    // Total scrollable content size
    const contentHeight = contentSize.height;
    const contentWidth = contentSize.width;
    
    // Visible area size
    const visibleHeight = layoutMeasurement.height;
    const visibleWidth = layoutMeasurement.width;
    
    // Calculate scroll percentage
    const scrollPercentage = scrollY / (contentHeight - visibleHeight);
    
    console.log(`Scrolled: ${(scrollPercentage * 100).toFixed(1)}%`);
  };

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      onScroll={handleScroll}
      scrollEventThrottle={16}  // How often to fire (16 = 60fps)
    />
  );
}

⚠️ scrollEventThrottle

On iOS, onScroll fires at 60fps by default, which can hurt performance. Use scrollEventThrottle to control the frequency:

  • 16: Every frame (60fps) - for smooth animations
  • 100: ~10 times per second - for most tracking
  • 500: ~2 times per second - for infrequent updates

Hiding Header on Scroll

import { useRef } from 'react';
import { Animated, FlatList } from 'react-native';

function HidingHeaderList() {
  const scrollY = useRef(new Animated.Value(0)).current;
  const lastScrollY = useRef(0);
  const headerVisible = useRef(new Animated.Value(1)).current;

  const handleScroll = Animated.event(
    [{ nativeEvent: { contentOffset: { y: scrollY } } }],
    {
      useNativeDriver: true,
      listener: (event) => {
        const currentScrollY = event.nativeEvent.contentOffset.y;
        
        // Scrolling down - hide header
        if (currentScrollY > lastScrollY.current && currentScrollY > 50) {
          Animated.timing(headerVisible, {
            toValue: 0,
            duration: 200,
            useNativeDriver: true,
          }).start();
        }
        // Scrolling up - show header
        else if (currentScrollY < lastScrollY.current) {
          Animated.timing(headerVisible, {
            toValue: 1,
            duration: 200,
            useNativeDriver: true,
          }).start();
        }
        
        lastScrollY.current = currentScrollY;
      },
    }
  );

  const headerTranslateY = headerVisible.interpolate({
    inputRange: [0, 1],
    outputRange: [-60, 0],
  });

  return (
    <View style={{ flex: 1 }}>
      <Animated.View
        style={[
          styles.header,
          { transform: [{ translateY: headerTranslateY }] },
        ]}
      >
        <Text style={styles.headerText}>My App</Text>
      </Animated.View>
      
      <FlatList
        data={data}
        renderItem={renderItem}
        onScroll={handleScroll}
        scrollEventThrottle={16}
        contentContainerStyle={{ paddingTop: 60 }}
      />
    </View>
  );
}

Other Scroll Events

<FlatList
  data={data}
  renderItem={renderItem}
  
  // Fires when scrolling starts
  onScrollBeginDrag={(event) => {
    console.log('Started scrolling');
  }}
  
  // Fires when user lifts finger
  onScrollEndDrag={(event) => {
    console.log('Stopped dragging');
  }}
  
  // Fires when momentum scroll ends
  onMomentumScrollBegin={(event) => {
    console.log('Momentum scroll started');
  }}
  
  onMomentumScrollEnd={(event) => {
    console.log('Momentum scroll ended');
    // Good place to load visible images, etc.
  }}
/>

Using Animated for Smooth Effects

import { Animated } from 'react-native';

function ParallaxHeaderList() {
  const scrollY = useRef(new Animated.Value(0)).current;

  // Parallax effect: header moves slower than content
  const headerTranslate = scrollY.interpolate({
    inputRange: [0, 200],
    outputRange: [0, -100],
    extrapolate: 'clamp',
  });

  // Fade header as you scroll
  const headerOpacity = scrollY.interpolate({
    inputRange: [0, 150],
    outputRange: [1, 0],
    extrapolate: 'clamp',
  });

  return (
    <View style={{ flex: 1 }}>
      <Animated.View
        style={[
          styles.parallaxHeader,
          {
            transform: [{ translateY: headerTranslate }],
            opacity: headerOpacity,
          },
        ]}
      >
        <Image source={headerImage} style={styles.headerImage} />
      </Animated.View>
      
      <Animated.FlatList
        data={data}
        renderItem={renderItem}
        onScroll={Animated.event(
          [{ nativeEvent: { contentOffset: { y: scrollY } } }],
          { useNativeDriver: true }
        )}
        scrollEventThrottle={16}
        contentContainerStyle={{ paddingTop: 200 }}
      />
    </View>
  );
}

Multi-Column Grids

FlatList supports grid layouts with the numColumns prop. This is perfect for photo galleries, product grids, and tile-based interfaces.

Basic Grid

import { FlatList, Dimensions } from 'react-native';

const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_SIZE = SCREEN_WIDTH / NUM_COLUMNS;

function BasicGrid() {
  return (
    <FlatList
      data={items}
      numColumns={NUM_COLUMNS}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <View style={{
          width: ITEM_SIZE,
          height: ITEM_SIZE,
          backgroundColor: item.color,
        }} />
      )}
    />
  );
}

Grid with Gaps

const NUM_COLUMNS = 2;
const GAP = 12;
const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_WIDTH = (SCREEN_WIDTH - GAP * (NUM_COLUMNS + 1)) / NUM_COLUMNS;

function GappedGrid() {
  return (
    <FlatList
      data={products}
      numColumns={NUM_COLUMNS}
      keyExtractor={(item) => item.id}
      columnWrapperStyle={{
        justifyContent: 'space-between',
        paddingHorizontal: GAP,
        marginBottom: GAP,
      }}
      contentContainerStyle={{
        paddingTop: GAP,
      }}
      renderItem={({ item }) => (
        <View style={{
          width: ITEM_WIDTH,
          backgroundColor: '#fff',
          borderRadius: 8,
          padding: 12,
        }}>
          <Image 
            source={{ uri: item.image }} 
            style={{ width: '100%', aspectRatio: 1 }}
          />
          <Text>{item.name}</Text>
          <Text>${item.price}</Text>
        </View>
      )}
    />
  );
}
numColumns Comparison numColumns=1 numColumns=2 numColumns=3 Full width items 2 per row 3 per row

Handling Incomplete Rows

When your data doesn't divide evenly into columns, the last row may have fewer items:

const NUM_COLUMNS = 3;

// Option 1: Pad with empty items
const padData = (data: Item[], columns: number) => {
  const remainder = data.length % columns;
  if (remainder === 0) return data;
  
  const padding = columns - remainder;
  return [...data, ...Array(padding).fill({ id: 'empty', isEmpty: true })];
};

function PaddedGrid({ items }) {
  const paddedItems = padData(items, NUM_COLUMNS);

  return (
    <FlatList
      data={paddedItems}
      numColumns={NUM_COLUMNS}
      renderItem={({ item }) => {
        if (item.isEmpty) {
          return <View style={{ flex: 1 }} />; // Empty spacer
        }
        return <GridItem item={item} />;
      }}
    />
  );
}

// Option 2: Use columnWrapperStyle for spacing
function SpacedGrid({ items }) {
  return (
    <FlatList
      data={items}
      numColumns={NUM_COLUMNS}
      columnWrapperStyle={{
        justifyContent: 'flex-start', // Left-align incomplete rows
        gap: 8, // Consistent gap between items
      }}
      renderItem={({ item }) => (
        <View style={{ width: ITEM_WIDTH }}>
          <GridItem item={item} />
        </View>
      )}
    />
  );
}

getItemLayout for Grids

const NUM_COLUMNS = 2;
const ITEM_HEIGHT = 180;
const GAP = 12;
const ROW_HEIGHT = ITEM_HEIGHT + GAP;

function OptimizedGrid({ items }) {
  const getItemLayout = useCallback(
    (data: Item[] | null | undefined, index: number) => {
      // In a grid, items are arranged in rows
      // Row index = floor(item index / num columns)
      const rowIndex = Math.floor(index / NUM_COLUMNS);
      
      return {
        length: ITEM_HEIGHT,
        offset: ROW_HEIGHT * rowIndex,
        index,
      };
    },
    []
  );

  return (
    <FlatList
      data={items}
      numColumns={NUM_COLUMNS}
      renderItem={renderItem}
      getItemLayout={getItemLayout}
    />
  );
}

⚠️ Grid Limitations

  • numColumns is static: Changing it requires remounting the FlatList
  • No staggered grids: All items in a row must have the same height
  • Horizontal grids: Can't combine numColumns with horizontal

For staggered/masonry grids, consider libraries like @shopify/flash-list with masonry layout or react-native-masonry-list.

Inverted Lists

Inverted lists render from bottom to topβ€”essential for chat interfaces where the newest messages appear at the bottom.

Basic Inverted List

// Chat messages - newest at bottom
function ChatMessages({ messages }) {
  return (
    <FlatList
      data={messages}
      renderItem={({ item }) => <MessageBubble message={item} />}
      keyExtractor={(item) => item.id}
      inverted  // This single prop inverts the list
    />
  );
}

// ⚠️ Important: Your data should be newest-first!
const messages = [
  { id: '3', text: 'Latest message', time: '10:03' },   // Bottom
  { id: '2', text: 'Earlier message', time: '10:02' },  // Middle
  { id: '1', text: 'First message', time: '10:01' },    // Top
];

Complete Chat Implementation

import React, { useState, useCallback, useRef } from 'react';
import {
  FlatList,
  View,
  Text,
  TextInput,
  Pressable,
  StyleSheet,
  KeyboardAvoidingView,
  Platform,
} from 'react-native';

interface Message {
  id: string;
  text: string;
  sender: 'me' | 'them';
  timestamp: Date;
}

export default function ChatScreen() {
  const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
  const [inputText, setInputText] = useState('');
  const flatListRef = useRef<FlatList>(null);

  const sendMessage = useCallback(() => {
    if (!inputText.trim()) return;

    const newMessage: Message = {
      id: Date.now().toString(),
      text: inputText.trim(),
      sender: 'me',
      timestamp: new Date(),
    };

    // Add to beginning (newest first for inverted list)
    setMessages(prev => [newMessage, ...prev]);
    setInputText('');
    
    // Auto-scroll to bottom (which is the top in inverted)
    // Actually not needed - inverted list handles this automatically
  }, [inputText]);

  const renderMessage = useCallback(({ item }: { item: Message }) => (
    <View style={[
      styles.messageBubble,
      item.sender === 'me' ? styles.myMessage : styles.theirMessage,
    ]}>
      <Text style={[
        styles.messageText,
        item.sender === 'me' ? styles.myMessageText : styles.theirMessageText,
      ]}>
        {item.text}
      </Text>
      <Text style={styles.timestamp}>
        {item.timestamp.toLocaleTimeString([], { 
          hour: '2-digit', 
          minute: '2-digit' 
        })}
      </Text>
    </View>
  ), []);

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : undefined}
      keyboardVerticalOffset={90}
    >
      <FlatList
        ref={flatListRef}
        data={messages}
        renderItem={renderMessage}
        keyExtractor={(item) => item.id}
        inverted
        contentContainerStyle={styles.messageList}
      />
      
      <View style={styles.inputContainer}>
        <TextInput
          style={styles.input}
          value={inputText}
          onChangeText={setInputText}
          placeholder="Type a message..."
          multiline
          maxLength={500}
        />
        <Pressable
          style={[styles.sendButton, !inputText.trim() && styles.sendButtonDisabled]}
          onPress={sendMessage}
          disabled={!inputText.trim()}
        >
          <Text style={styles.sendButtonText}>Send</Text>
        </Pressable>
      </View>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  messageList: {
    padding: 16,
  },
  messageBubble: {
    maxWidth: '80%',
    padding: 12,
    borderRadius: 16,
    marginBottom: 8,
  },
  myMessage: {
    alignSelf: 'flex-end',
    backgroundColor: '#6200ee',
    borderBottomRightRadius: 4,
  },
  theirMessage: {
    alignSelf: 'flex-start',
    backgroundColor: '#fff',
    borderBottomLeftRadius: 4,
  },
  messageText: {
    fontSize: 16,
    lineHeight: 22,
  },
  myMessageText: {
    color: '#fff',
  },
  theirMessageText: {
    color: '#333',
  },
  timestamp: {
    fontSize: 11,
    color: 'rgba(255,255,255,0.7)',
    marginTop: 4,
    alignSelf: 'flex-end',
  },
  inputContainer: {
    flexDirection: 'row',
    padding: 12,
    backgroundColor: '#fff',
    borderTopWidth: 1,
    borderTopColor: '#e0e0e0',
    alignItems: 'flex-end',
  },
  input: {
    flex: 1,
    backgroundColor: '#f5f5f5',
    borderRadius: 20,
    paddingHorizontal: 16,
    paddingVertical: 10,
    marginRight: 8,
    maxHeight: 100,
    fontSize: 16,
  },
  sendButton: {
    backgroundColor: '#6200ee',
    borderRadius: 20,
    paddingHorizontal: 20,
    paddingVertical: 10,
  },
  sendButtonDisabled: {
    backgroundColor: '#ccc',
  },
  sendButtonText: {
    color: '#fff',
    fontWeight: '600',
  },
});
flowchart LR
    subgraph Normal["Normal List"]
        direction TB
        N1["Item 1 (oldest)"] --> N2["Item 2"]
        N2 --> N3["Item 3 (newest)"]
    end
    
    subgraph Inverted["Inverted List"]
        direction TB
        I3["Item 3 (newest)"] --> I2["Item 2"]
        I2 --> I1["Item 1 (oldest)"]
    end
    
    subgraph Data["Data Array"]
        D["[newest, ..., oldest]"]
    end
    
    Data --> Normal
    Data --> Inverted
    
    style N3 fill:#c8e6c9
    style I3 fill:#c8e6c9
                

Sticky Headers

Sticky headers remain visible at the top of the screen as you scroll. FlatList supports them through the stickyHeaderIndices prop.

Basic Sticky Header

// Make specific indices sticky
function StickyHeaderList() {
  const data = [
    { type: 'header', title: 'Section A' },
    { type: 'item', name: 'Item 1' },
    { type: 'item', name: 'Item 2' },
    { type: 'header', title: 'Section B' },
    { type: 'item', name: 'Item 3' },
    { type: 'item', name: 'Item 4' },
  ];

  // Find indices of headers
  const stickyIndices = data
    .map((item, index) => item.type === 'header' ? index : null)
    .filter(index => index !== null) as number[];

  return (
    <FlatList
      data={data}
      stickyHeaderIndices={stickyIndices}
      renderItem={({ item }) => {
        if (item.type === 'header') {
          return (
            <View style={styles.stickyHeader}>
              <Text style={styles.headerText}>{item.title}</Text>
            </View>
          );
        }
        return (
          <View style={styles.item}>
            <Text>{item.name}</Text>
          </View>
        );
      }}
    />
  );
}

const styles = StyleSheet.create({
  stickyHeader: {
    backgroundColor: '#6200ee',
    padding: 12,
  },
  headerText: {
    color: '#fff',
    fontWeight: 'bold',
    fontSize: 16,
  },
  item: {
    padding: 16,
    backgroundColor: '#fff',
    borderBottomWidth: 1,
    borderBottomColor: '#e0e0e0',
  },
});

πŸ’‘ Sticky Headers vs SectionList

If your data is naturally grouped with headers, consider using SectionList instead of FlatList with stickyHeaderIndices. SectionList has built-in support for section headers and makes the code cleaner. We'll cover SectionList in the next lesson.

Sticky ListHeaderComponent

// Make the list header sticky
function StickyListHeader() {
  return (
    <FlatList
      data={items}
      renderItem={renderItem}
      ListHeaderComponent={
        <View style={styles.searchHeader}>
          <TextInput
            style={styles.searchInput}
            placeholder="Search..."
          />
        </View>
      }
      // Index 0 is the ListHeaderComponent
      stickyHeaderIndices={[0]}
      // Hide header shadow on iOS
      stickyHeaderHiddenOnScroll={false}
    />
  );
}

Styling Sticky Headers

// Add shadows for depth when stuck
const styles = StyleSheet.create({
  stickyHeader: {
    backgroundColor: '#fff',
    padding: 12,
    borderBottomWidth: 1,
    borderBottomColor: '#e0e0e0',
    // iOS shadow
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    // Android elevation
    elevation: 4,
    // Ensure header is above items
    zIndex: 1,
  },
});

Scroll Indicators

Control the visibility and style of scroll indicators (scrollbars) with these props.

Visibility Control

<FlatList
  data={items}
  renderItem={renderItem}
  
  // Hide indicators
  showsVerticalScrollIndicator={false}
  showsHorizontalScrollIndicator={false}
  
  // Or show them (default)
  showsVerticalScrollIndicator={true}
/>

iOS-Specific Styling

<FlatList
  data={items}
  renderItem={renderItem}
  
  // iOS scroll indicator style
  indicatorStyle="black"  // 'default' | 'black' | 'white'
  
  // Inset the indicators from edges
  scrollIndicatorInsets={{
    top: 10,
    right: 5,
    bottom: 10,
    left: 0,
  }}
/>

When to Hide Scroll Indicators

🎨 Design Guidelines

Show Indicators Hide Indicators
Long content lists (feeds, search results) Horizontal carousels
Settings/forms Tab bars/category chips
Document viewers Image galleries
Any list where scroll position matters Short lists that fit on screen

Keyboard Handling

When your list contains input fields or needs to work alongside an input, proper keyboard handling ensures a smooth user experience.

Keyboard Dismiss Modes

<FlatList
  data={items}
  renderItem={renderItem}
  
  // Dismiss keyboard behavior
  keyboardDismissMode="none"        // Default - keyboard stays open
  keyboardDismissMode="on-drag"     // Dismiss when user drags
  keyboardDismissMode="interactive" // iOS: drag dismisses progressively
  
  // Should touches dismiss keyboard?
  keyboardShouldPersistTaps="never"   // Default - dismiss on any tap
  keyboardShouldPersistTaps="always"  // Never dismiss on tap
  keyboardShouldPersistTaps="handled" // Dismiss unless tap is handled
/>

Common Patterns

// Search list - dismiss keyboard when scrolling results
function SearchList() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  return (
    <View style={{ flex: 1 }}>
      <TextInput
        style={styles.searchInput}
        value={query}
        onChangeText={setQuery}
        placeholder="Search..."
      />
      
      <FlatList
        data={results}
        renderItem={renderItem}
        keyboardDismissMode="on-drag"
        keyboardShouldPersistTaps="handled"
      />
    </View>
  );
}

// Form list - keep keyboard open when tapping buttons
function FormList() {
  return (
    <FlatList
      data={formFields}
      renderItem={({ item }) => (
        <TextInput
          style={styles.input}
          placeholder={item.label}
        />
      )}
      keyboardShouldPersistTaps="always"
      ListFooterComponent={
        <Pressable style={styles.submitButton}>
          <Text>Submit</Text>
        </Pressable>
      }
    />
  );
}

Automatic Scroll to Input

import { useRef } from 'react';
import { FlatList, TextInput, Keyboard } from 'react-native';

function AutoScrollForm() {
  const flatListRef = useRef<FlatList>(null);
  const inputRefs = useRef<Record<string, TextInput>>({});

  const handleFocus = (index: number) => {
    // Scroll to bring the focused input into view
    flatListRef.current?.scrollToIndex({
      index,
      viewPosition: 0.3, // Show input in upper third
      animated: true,
    });
  };

  return (
    <FlatList
      ref={flatListRef}
      data={formFields}
      keyExtractor={(item) => item.id}
      getItemLayout={getItemLayout}
      keyboardShouldPersistTaps="handled"
      renderItem={({ item, index }) => (
        <TextInput
          ref={(ref) => { inputRefs.current[item.id] = ref!; }}
          style={styles.input}
          placeholder={item.label}
          onFocus={() => handleFocus(index)}
          returnKeyType={index === formFields.length - 1 ? 'done' : 'next'}
          onSubmitEditing={() => {
            if (index < formFields.length - 1) {
              inputRefs.current[formFields[index + 1].id]?.focus();
            } else {
              Keyboard.dismiss();
            }
          }}
        />
      )}
    />
  );
}

βœ… Keyboard Best Practices

  • Search screens: Use keyboardDismissMode="on-drag"
  • Forms: Use keyboardShouldPersistTaps="handled"
  • Chat: Use keyboardShouldPersistTaps="always" with inverted
  • Always test on real devices: Simulator keyboards behave differently

Hands-On Exercises

Practice implementing these FlatList features in real-world scenarios.

Exercise 1: Social Media Feed

Build a complete social media feed with pull-to-refresh and infinite scroll.

Requirements:

  • Pull-to-refresh that fetches new posts
  • Infinite scroll that loads older posts
  • Loading indicator in the footer
  • "No more posts" message when all posts are loaded
  • Error handling with retry option
πŸ’‘ Hint

Use separate state for refreshing and loadingMore. Track hasMore to know when to stop loading. Use useRef for the loading lock to prevent race conditions.

βœ… Solution
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
  FlatList,
  View,
  Text,
  Image,
  ActivityIndicator,
  Pressable,
  RefreshControl,
  StyleSheet,
} from 'react-native';

interface Post {
  id: string;
  author: string;
  avatar: string;
  content: string;
  likes: number;
  timestamp: string;
}

// Simulate API
const fetchPosts = async (page: number, refresh = false): Promise<{
  posts: Post[];
  hasMore: boolean;
}> => {
  await new Promise(r => setTimeout(r, 1000));
  
  // Simulate 5 pages of content
  if (page > 5) return { posts: [], hasMore: false };
  
  const posts = Array.from({ length: 10 }, (_, i) => ({
    id: `${refresh ? 'new-' : ''}${page}-${i}-${Date.now()}`,
    author: `User ${Math.floor(Math.random() * 100)}`,
    avatar: `https://i.pravatar.cc/100?img=${(page * 10 + i) % 70}`,
    content: `This is post #${(page - 1) * 10 + i + 1}. Lorem ipsum dolor sit amet.`,
    likes: Math.floor(Math.random() * 500),
    timestamp: new Date(Date.now() - Math.random() * 86400000).toISOString(),
  }));
  
  return { posts, hasMore: page < 5 };
};

const PostCard = React.memo(function PostCard({ post }: { post: Post }) {
  return (
    <View style={styles.postCard}>
      <View style={styles.postHeader}>
        <Image source={{ uri: post.avatar }} style={styles.avatar} />
        <View>
          <Text style={styles.authorName}>{post.author}</Text>
          <Text style={styles.timestamp}>
            {new Date(post.timestamp).toLocaleString()}
          </Text>
        </View>
      </View>
      <Text style={styles.content}>{post.content}</Text>
      <Text style={styles.likes}>❀️ {post.likes} likes</Text>
    </View>
  );
});

export default function SocialFeed() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [refreshing, setRefreshing] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  const page = useRef(1);
  const isLoadingMore = useRef(false);

  // Initial load
  useEffect(() => {
    loadInitial();
  }, []);

  const loadInitial = async () => {
    try {
      const result = await fetchPosts(1);
      setPosts(result.posts);
      setHasMore(result.hasMore);
      page.current = 1;
    } catch (err) {
      setError('Failed to load posts');
    }
  };

  const onRefresh = useCallback(async () => {
    setRefreshing(true);
    setError(null);
    
    try {
      const result = await fetchPosts(1, true);
      setPosts(result.posts);
      setHasMore(result.hasMore);
      page.current = 1;
    } catch (err) {
      setError('Failed to refresh');
    } finally {
      setRefreshing(false);
    }
  }, []);

  const loadMore = useCallback(async () => {
    if (isLoadingMore.current || !hasMore) return;
    
    isLoadingMore.current = true;
    
    try {
      const nextPage = page.current + 1;
      const result = await fetchPosts(nextPage);
      
      setPosts(prev => [...prev, ...result.posts]);
      setHasMore(result.hasMore);
      page.current = nextPage;
    } catch (err) {
      setError('Failed to load more');
    } finally {
      isLoadingMore.current = false;
    }
  }, [hasMore]);

  const renderItem = useCallback(
    ({ item }: { item: Post }) => <PostCard post={item} />,
    []
  );

  const renderFooter = () => {
    if (error && posts.length > 0) {
      return (
        <Pressable style={styles.errorFooter} onPress={loadMore}>
          <Text style={styles.errorText}>{error}</Text>
          <Text style={styles.retryText}>Tap to retry</Text>
        </Pressable>
      );
    }
    
    if (!hasMore) {
      return (
        <View style={styles.endFooter}>
          <Text style={styles.endText}>πŸŽ‰ You're all caught up!</Text>
        </View>
      );
    }
    
    return (
      <View style={styles.loadingFooter}>
        <ActivityIndicator color="#6200ee" />
      </View>
    );
  };

  return (
    <FlatList
      data={posts}
      renderItem={renderItem}
      keyExtractor={(item) => item.id}
      refreshControl={
        <RefreshControl
          refreshing={refreshing}
          onRefresh={onRefresh}
          tintColor="#6200ee"
          colors={['#6200ee']}
        />
      }
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
      ListFooterComponent={renderFooter}
      contentContainerStyle={styles.listContent}
    />
  );
}

const styles = StyleSheet.create({
  listContent: {
    padding: 12,
  },
  postCard: {
    backgroundColor: '#fff',
    borderRadius: 12,
    padding: 16,
    marginBottom: 12,
    elevation: 2,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 3,
  },
  postHeader: {
    flexDirection: 'row',
    alignItems: 'center',
    marginBottom: 12,
  },
  avatar: {
    width: 44,
    height: 44,
    borderRadius: 22,
    marginRight: 12,
  },
  authorName: {
    fontWeight: '600',
    fontSize: 15,
  },
  timestamp: {
    color: '#666',
    fontSize: 12,
    marginTop: 2,
  },
  content: {
    fontSize: 15,
    lineHeight: 22,
    marginBottom: 12,
  },
  likes: {
    color: '#666',
    fontSize: 13,
  },
  loadingFooter: {
    paddingVertical: 24,
    alignItems: 'center',
  },
  errorFooter: {
    paddingVertical: 24,
    alignItems: 'center',
  },
  errorText: {
    color: '#f44336',
  },
  retryText: {
    color: '#6200ee',
    fontWeight: '600',
    marginTop: 4,
  },
  endFooter: {
    paddingVertical: 24,
    alignItems: 'center',
  },
  endText: {
    color: '#666',
    fontSize: 15,
  },
});

Exercise 2: Photo Grid Gallery

Build a 3-column photo grid with a "scroll to top" button.

Requirements:

  • 3-column grid with proper spacing
  • Square image cells
  • Scroll to top button that appears after scrolling down
  • Smooth animated scroll to top
  • Hide scroll indicator
πŸ’‘ Hint

Use numColumns={3} and calculate item size from screen width. Track scroll position with onScroll to show/hide the button. Use scrollToOffset for the smooth scroll.

βœ… Solution
import React, { useState, useCallback, useRef } from 'react';
import {
  FlatList,
  View,
  Image,
  Pressable,
  Text,
  Dimensions,
  StyleSheet,
  Animated,
} from 'react-native';

const NUM_COLUMNS = 3;
const GAP = 2;
const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_SIZE = (SCREEN_WIDTH - GAP * (NUM_COLUMNS - 1)) / NUM_COLUMNS;

interface Photo {
  id: string;
  uri: string;
}

// Generate mock photos
const generatePhotos = (count: number): Photo[] =>
  Array.from({ length: count }, (_, i) => ({
    id: `photo-${i}`,
    uri: `https://picsum.photos/seed/${i}/400/400`,
  }));

const PhotoCell = React.memo(function PhotoCell({ photo }: { photo: Photo }) {
  return (
    <Pressable style={styles.photoCell}>
      <Image source={{ uri: photo.uri }} style={styles.photo} />
    </Pressable>
  );
});

export default function PhotoGrid() {
  const [photos] = useState(() => generatePhotos(100));
  const [showScrollTop, setShowScrollTop] = useState(false);
  
  const flatListRef = useRef<FlatList>(null);
  const buttonOpacity = useRef(new Animated.Value(0)).current;

  const handleScroll = useCallback((event) => {
    const offsetY = event.nativeEvent.contentOffset.y;
    const shouldShow = offsetY > 500;
    
    if (shouldShow !== showScrollTop) {
      setShowScrollTop(shouldShow);
      Animated.timing(buttonOpacity, {
        toValue: shouldShow ? 1 : 0,
        duration: 200,
        useNativeDriver: true,
      }).start();
    }
  }, [showScrollTop, buttonOpacity]);

  const scrollToTop = useCallback(() => {
    flatListRef.current?.scrollToOffset({
      offset: 0,
      animated: true,
    });
  }, []);

  const renderItem = useCallback(
    ({ item }: { item: Photo }) => <PhotoCell photo={item} />,
    []
  );

  const getItemLayout = useCallback(
    (data: Photo[] | null | undefined, index: number) => {
      const rowIndex = Math.floor(index / NUM_COLUMNS);
      return {
        length: ITEM_SIZE,
        offset: (ITEM_SIZE + GAP) * rowIndex,
        index,
      };
    },
    []
  );

  return (
    <View style={styles.container}>
      <FlatList
        ref={flatListRef}
        data={photos}
        renderItem={renderItem}
        keyExtractor={(item) => item.id}
        numColumns={NUM_COLUMNS}
        getItemLayout={getItemLayout}
        onScroll={handleScroll}
        scrollEventThrottle={100}
        showsVerticalScrollIndicator={false}
        columnWrapperStyle={styles.row}
      />
      
      <Animated.View style={[styles.scrollTopButton, { opacity: buttonOpacity }]}>
        <Pressable onPress={scrollToTop} style={styles.buttonInner}>
          <Text style={styles.buttonText}>↑</Text>
        </Pressable>
      </Animated.View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#000',
  },
  row: {
    gap: GAP,
    marginBottom: GAP,
  },
  photoCell: {
    width: ITEM_SIZE,
    height: ITEM_SIZE,
  },
  photo: {
    width: '100%',
    height: '100%',
  },
  scrollTopButton: {
    position: 'absolute',
    bottom: 30,
    right: 20,
  },
  buttonInner: {
    width: 50,
    height: 50,
    borderRadius: 25,
    backgroundColor: 'rgba(255,255,255,0.9)',
    justifyContent: 'center',
    alignItems: 'center',
    elevation: 4,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 4,
  },
  buttonText: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#333',
  },
});

Exercise 3: Simple Chat Interface

Build a basic chat interface with an inverted list and message input.

Requirements:

  • Inverted FlatList for messages
  • Messages from "me" aligned right with a different color
  • Messages from "them" aligned left
  • Text input at bottom with send button
  • New messages appear at bottom automatically
  • Keyboard handling (dismiss on drag)
πŸ’‘ Hint

Use inverted prop. Store messages newest-first in state. Wrap in KeyboardAvoidingView for proper keyboard handling. Use alignSelf for message alignment.

βœ… Solution
import React, { useState, useCallback } from 'react';
import {
  FlatList,
  View,
  Text,
  TextInput,
  Pressable,
  StyleSheet,
  KeyboardAvoidingView,
  Platform,
} from 'react-native';

interface Message {
  id: string;
  text: string;
  sender: 'me' | 'them';
  timestamp: Date;
}

const INITIAL_MESSAGES: Message[] = [
  { id: '3', text: 'Looking forward to it!', sender: 'them', timestamp: new Date() },
  { id: '2', text: 'Sure, how about tomorrow at 2pm?', sender: 'me', timestamp: new Date() },
  { id: '1', text: 'Hey! Want to grab coffee sometime?', sender: 'them', timestamp: new Date() },
];

const MessageBubble = React.memo(function MessageBubble({ 
  message 
}: { 
  message: Message 
}) {
  const isMe = message.sender === 'me';
  
  return (
    <View style={[
      styles.bubble,
      isMe ? styles.myBubble : styles.theirBubble,
    ]}>
      <Text style={[
        styles.bubbleText,
        isMe ? styles.myBubbleText : styles.theirBubbleText,
      ]}>
        {message.text}
      </Text>
      <Text style={[
        styles.time,
        isMe ? styles.myTime : styles.theirTime,
      ]}>
        {message.timestamp.toLocaleTimeString([], { 
          hour: '2-digit', 
          minute: '2-digit' 
        })}
      </Text>
    </View>
  );
});

export default function ChatInterface() {
  const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
  const [inputText, setInputText] = useState('');

  const sendMessage = useCallback(() => {
    if (!inputText.trim()) return;

    const newMessage: Message = {
      id: Date.now().toString(),
      text: inputText.trim(),
      sender: 'me',
      timestamp: new Date(),
    };

    // Add to beginning (newest first for inverted list)
    setMessages(prev => [newMessage, ...prev]);
    setInputText('');
    
    // Simulate reply after 1 second
    setTimeout(() => {
      const reply: Message = {
        id: (Date.now() + 1).toString(),
        text: 'Got it! πŸ‘',
        sender: 'them',
        timestamp: new Date(),
      };
      setMessages(prev => [reply, ...prev]);
    }, 1000);
  }, [inputText]);

  const renderItem = useCallback(
    ({ item }: { item: Message }) => <MessageBubble message={item} />,
    []
  );

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : undefined}
      keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
    >
      <FlatList
        data={messages}
        renderItem={renderItem}
        keyExtractor={(item) => item.id}
        inverted
        contentContainerStyle={styles.messageList}
        keyboardDismissMode="on-drag"
        keyboardShouldPersistTaps="handled"
      />
      
      <View style={styles.inputContainer}>
        <TextInput
          style={styles.input}
          value={inputText}
          onChangeText={setInputText}
          placeholder="Type a message..."
          multiline
          maxLength={500}
          returnKeyType="send"
          onSubmitEditing={sendMessage}
          blurOnSubmit={false}
        />
        <Pressable
          style={[
            styles.sendButton,
            !inputText.trim() && styles.sendButtonDisabled,
          ]}
          onPress={sendMessage}
          disabled={!inputText.trim()}
        >
          <Text style={styles.sendText}>➀</Text>
        </Pressable>
      </View>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#e5ddd5',
  },
  messageList: {
    padding: 16,
  },
  bubble: {
    maxWidth: '75%',
    padding: 10,
    paddingHorizontal: 14,
    borderRadius: 18,
    marginBottom: 8,
  },
  myBubble: {
    alignSelf: 'flex-end',
    backgroundColor: '#dcf8c6',
    borderBottomRightRadius: 4,
  },
  theirBubble: {
    alignSelf: 'flex-start',
    backgroundColor: '#fff',
    borderBottomLeftRadius: 4,
  },
  bubbleText: {
    fontSize: 16,
    lineHeight: 22,
  },
  myBubbleText: {
    color: '#000',
  },
  theirBubbleText: {
    color: '#000',
  },
  time: {
    fontSize: 11,
    marginTop: 4,
  },
  myTime: {
    color: '#7a8c6e',
    alignSelf: 'flex-end',
  },
  theirTime: {
    color: '#999',
    alignSelf: 'flex-end',
  },
  inputContainer: {
    flexDirection: 'row',
    padding: 8,
    backgroundColor: '#f0f0f0',
    alignItems: 'flex-end',
  },
  input: {
    flex: 1,
    backgroundColor: '#fff',
    borderRadius: 24,
    paddingHorizontal: 16,
    paddingVertical: 10,
    paddingRight: 16,
    marginRight: 8,
    maxHeight: 100,
    fontSize: 16,
  },
  sendButton: {
    width: 44,
    height: 44,
    borderRadius: 22,
    backgroundColor: '#25D366',
    justifyContent: 'center',
    alignItems: 'center',
  },
  sendButtonDisabled: {
    backgroundColor: '#ccc',
  },
  sendText: {
    fontSize: 20,
    color: '#fff',
  },
});

Exercise 4: Alphabet Scroll Index

Build a contacts list with an alphabet index for quick navigation.

Requirements:

  • Contacts sorted alphabetically
  • Right-side alphabet strip (A-Z)
  • Tap a letter to scroll to that section
  • Use scrollToIndex for navigation
  • Implement getItemLayout for accurate scrolling
πŸ’‘ Hint

Create an index map that stores the first occurrence index of each letter. When tapping a letter, look up its index and use scrollToIndex. Make sure to implement getItemLayout for accurate positioning.

βœ… Solution
import React, { useCallback, useMemo, useRef } from 'react';
import {
  FlatList,
  View,
  Text,
  Pressable,
  StyleSheet,
} from 'react-native';

interface Contact {
  id: string;
  name: string;
}

// Generate alphabetically sorted contacts
const generateContacts = (): Contact[] => {
  const names = [
    'Alice', 'Amanda', 'Amy', 'Bob', 'Brian', 'Carol', 'Charlie', 
    'David', 'Diana', 'Edward', 'Emily', 'Frank', 'George', 'Hannah',
    'Ivan', 'Julia', 'Kevin', 'Laura', 'Michael', 'Nancy', 'Oliver',
    'Patricia', 'Quinn', 'Rachel', 'Steven', 'Tina', 'Uma', 'Victor',
    'William', 'Xavier', 'Yolanda', 'Zachary',
  ];
  
  return names.map((name, i) => ({
    id: `contact-${i}`,
    name: `${name} ${['Smith', 'Johnson', 'Williams', 'Brown'][i % 4]}`,
  })).sort((a, b) => a.name.localeCompare(b.name));
};

const ITEM_HEIGHT = 56;
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');

export default function AlphabetScrollList() {
  const contacts = useMemo(() => generateContacts(), []);
  const flatListRef = useRef<FlatList>(null);

  // Build index map: letter -> first contact index
  const letterIndexMap = useMemo(() => {
    const map: Record<string, number> = {};
    contacts.forEach((contact, index) => {
      const letter = contact.name[0].toUpperCase();
      if (!(letter in map)) {
        map[letter] = index;
      }
    });
    return map;
  }, [contacts]);

  const scrollToLetter = useCallback((letter: string) => {
    const index = letterIndexMap[letter];
    if (index !== undefined) {
      flatListRef.current?.scrollToIndex({
        index,
        animated: true,
        viewPosition: 0,
      });
    }
  }, [letterIndexMap]);

  const getItemLayout = useCallback(
    (data: Contact[] | null | undefined, index: number) => ({
      length: ITEM_HEIGHT,
      offset: ITEM_HEIGHT * index,
      index,
    }),
    []
  );

  const renderItem = useCallback(({ item }: { item: Contact }) => (
    <View style={styles.contactItem}>
      <View style={styles.avatar}>
        <Text style={styles.avatarText}>{item.name[0]}</Text>
      </View>
      <Text style={styles.contactName}>{item.name}</Text>
    </View>
  ), []);

  return (
    <View style={styles.container}>
      <FlatList
        ref={flatListRef}
        data={contacts}
        renderItem={renderItem}
        keyExtractor={(item) => item.id}
        getItemLayout={getItemLayout}
        showsVerticalScrollIndicator={false}
      />
      
      {/* Alphabet Index */}
      <View style={styles.alphabetContainer}>
        {ALPHABET.map((letter) => {
          const hasContacts = letter in letterIndexMap;
          return (
            <Pressable
              key={letter}
              onPress={() => scrollToLetter(letter)}
              disabled={!hasContacts}
              style={styles.letterButton}
            >
              <Text style={[
                styles.letterText,
                !hasContacts && styles.letterDisabled,
              ]}>
                {letter}
              </Text>
            </Pressable>
          );
        })}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    flexDirection: 'row',
  },
  contactItem: {
    height: ITEM_HEIGHT,
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#f0f0f0',
  },
  avatar: {
    width: 40,
    height: 40,
    borderRadius: 20,
    backgroundColor: '#6200ee',
    justifyContent: 'center',
    alignItems: 'center',
    marginRight: 12,
  },
  avatarText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: 'bold',
  },
  contactName: {
    fontSize: 16,
  },
  alphabetContainer: {
    position: 'absolute',
    right: 0,
    top: 0,
    bottom: 0,
    width: 24,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(255,255,255,0.9)',
  },
  letterButton: {
    paddingVertical: 1,
    paddingHorizontal: 4,
  },
  letterText: {
    fontSize: 11,
    fontWeight: '600',
    color: '#6200ee',
  },
  letterDisabled: {
    color: '#ccc',
  },
});

Summary

You've now mastered the interactive features that make FlatList a powerful tool for building production-ready mobile apps.

🎯 Key Takeaways

  • Pull-to-refresh: Use refreshing and onRefresh props, or RefreshControl for customization
  • Infinite scroll: Use onEndReached with onEndReachedThreshold, guard against duplicate calls
  • Scroll methods: scrollToIndex, scrollToOffset, scrollToEnd for programmatic navigation
  • Scroll events: Use onScroll with scrollEventThrottle for custom behaviors
  • Multi-column: numColumns for grid layouts, calculate item sizes from screen width
  • Inverted: inverted prop for chat interfaces, data should be newest-first
  • Sticky headers: stickyHeaderIndices for headers that stay visible
  • Keyboard: keyboardDismissMode and keyboardShouldPersistTaps for input handling

Feature Quick Reference

flowchart LR
    subgraph Refresh["Data Loading"]
        R1["Pull-to-refresh"]
        R2["Infinite scroll"]
    end
    
    subgraph Navigation["Navigation"]
        N1["scrollToIndex"]
        N2["scrollToOffset"]
        N3["scrollToEnd"]
    end
    
    subgraph Layout["Layout"]
        L1["numColumns"]
        L2["inverted"]
        L3["stickyHeaderIndices"]
    end
    
    subgraph Events["Events"]
        E1["onScroll"]
        E2["onEndReached"]
        E3["keyboard modes"]
    end
    
    style R1 fill:#c8e6c9
    style R2 fill:#c8e6c9
    style N1 fill:#bbdefb
    style N2 fill:#bbdefb
    style N3 fill:#bbdefb
    style L1 fill:#fff3cd
    style L2 fill:#fff3cd
    style L3 fill:#fff3cd
                

πŸš€ What's Next?

Now that you're a FlatList expert, the next lesson covers SectionListβ€”FlatList's sibling for grouped data with section headers. You'll learn when to choose SectionList over FlatList, how to structure sectioned data, and how to create sticky section headers for contacts, settings, and grouped content screens.