EduFlow

Section 2 · Layout and Style

Lists That Stay Fast: FlatList and SectionList

22 min3 min readCode playground

Lesson at a glance

Render Trailhead’s growing hike log with FlatList and SectionList, understand virtualization, and apply the handful of optimizations that keep long lists smooth on mid-range phones.

  1. 1FlatList: render what’s visible
  2. 2How virtualization works
  3. 3Keeping it smooth
  4. 4SectionList: grouped data

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Trailhead’s log starts with a dozen hikes. A keen hiker logs three a week; after four years that’s over 600 rows, each with a thumbnail, a title and a few stats. Render that with ScrollView and .map(), and the app builds every row’s native views before showing the first frame. On a flagship phone you might not notice. On the mid-range Android phones most of the world uses, you get a blank screen and a stutter.

FlatList: render what’s visible

FlatList takes your data and a function to render one item, and it only mounts the rows near the viewport:

src/app/index.tsx
import { FlatList, Text, View } from 'react-native';
import { HikeRow } from '../components/HikeRow';
import { useHikes } from '../state/hikes';

export default function HikeLog() {
  const hikes = useHikes();

  return (
    <FlatList
      data={hikes}
      keyExtractor={(hike) => hike.id}
      renderItem={({ item }) => <HikeRow hike={item} />}
      ItemSeparatorComponent={() => <View style={{ height: 1, backgroundColor: '#e2e8e4', marginLeft: 80 }} />}
      ListEmptyComponent={<Text style={{ padding: 24, textAlign: 'center' }}>No hikes yet. Log your first one!</Text>}
      contentContainerStyle={{ paddingBottom: 24 }}
    />
  );
}

Like ScrollView, a FlatList needs a bounded height, so its parent must give it flex: 1 (a screen in Expo Router already does). renderItem receives { item, index }. ListHeaderComponent and ListFooterComponent let a summary card or a “load more” spinner scroll with the list, instead of wrapping the FlatList in a ScrollView, which defeats virtualization entirely and triggers a warning.

How virtualization works

FlatList keeps a render window: the rows on screen plus a buffer above and below. As you scroll, rows entering the window mount and rows leaving it unmount, replaced by empty space of the right height.

Of 600 hikes, only the rows on screen plus a buffer above and below are mounted; rows outside the window are replaced by blank space of the same height. rows 1–90: blank space buffer (mounted) visible rows buffer (mounted) rows 320–600: blank space phone screen viewport windowSize default 21 screens initialNumToRender default 10 rows
FlatList keeps only a window of rows mounted

Two consequences follow. First, keys must be stable: FlatList and React use them to know which row is which as rows come and go, so use an id from your data. Array indexes break the moment you insert a new hike at the top. Second, rows forget local state when they unmount. An “expanded” flag kept in useState inside the row resets after a long scroll. Lift that state into the parent (expandedId) or into your data.

Keeping it smooth

Most list jank comes from re-rendering rows that didn’t change, or doing heavy work inside them. In order of payoff:

  1. Memoize the row. Export HikeRow wrapped in memo, so a row re-renders only when its hike prop changes. (If your project enables the React Compiler, it memoizes for you.)
  2. Keep renderItem stable. An inline arrow function is fine with a memoized row, but don’t create new objects for props on every render, such as style={{…}} or hike={{ ...item }}, which defeat memo.
  3. No heavy work in rows. Format dates and compute stats once, when the data changes, not in every row’s render.
  4. Right-sized images. A 4000-pixel photo shrunk into a 56-point thumbnail costs memory for every visible row. Request thumbnails from your API, or use expo-image with caching.
  5. Fixed heights? Use getItemLayout. If every row is 72 points plus a 1-point separator, FlatList can skip measuring:
TSX
const ROW = 73;

<FlatList
  // …
  getItemLayout={(_, index) => ({ length: ROW, offset: ROW * index, index })}
/>

For logs that live on a server, don’t fetch all 600 hikes at once. onEndReached fires when the user scrolls near the bottom (how near is set by onEndReachedThreshold, measured in screen heights), which is the moment to request the next page and append it to data. Show a spinner in ListFooterComponent while it loads, and guard against firing the request twice.

Tuning windowSize and initialNumToRender comes last, and only after you’ve measured. For very large or complex feeds, many teams switch to Shopify’s FlashList, which recycles row views instead of mounting new ones and keeps a FlatList-like API.

SectionList: grouped data

Trailhead groups hikes by month, with a sticky header for each. SectionList does that from data shaped like this:

TypeScript
const sections = [
  { title: 'October 2026', data: [ridgeLoop, lakeTrail] },
  { title: 'September 2026', data: [oldMill] },
];
TSX
<SectionList
  sections={sections}
  keyExtractor={(hike) => hike.id}
  renderItem={({ item }) => <HikeRow hike={item} />}
  renderSectionHeader={({ section }) => <Text style={styles.header}>{section.title}</Text>}
  stickySectionHeadersEnabled
/>

The interesting part isn’t the component; it’s turning a flat array of hikes into that sections shape, correctly sorted. That’s your exercise.

Section 3 starts next: with real screens to move between, Trailhead needs navigation.

Key takeaways

  • ScrollView with .map() mounts every row at once; FlatList mounts only a window of rows around what’s visible.
  • Give FlatList a keyExtractor that returns a stable id from your data, never the array index.
  • Virtualized rows unmount when they scroll far away, so keep row state in your data, not inside the row component.
  • Memoize the row component, keep renderItem stable, and add getItemLayout when every row has the same height.
  • SectionList takes sections shaped as { title, data } and renders grouped lists with sticky headers.

Further reading

Check your understanding

3 questions

Question 1 of 3

Trailhead’s log has 600 hikes rendered with ScrollView and .map(). The screen takes three seconds to appear on an older Android phone. Why?

Question 2 of 3

Each hike row has a local useState for “expanded”. Users expand a row, scroll far down, scroll back, and it’s collapsed again. Why?

Question 3 of 3

Every Trailhead row is exactly 72 points tall. Which prop lets FlatList skip measuring rows and jump straight to row 400?

Practice

Code playground

Group hikes into SectionList sections

Write groupHikesByMonth(hikes) so Trailhead’s SectionList can render the log grouped by month. Return an array of sections shaped { title, data } where:

  • title is the month name and year, like "October 2026" (use the MONTHS array),
  • sections are ordered newest month first, and October 2026 and October 2025 are different sections,
  • hikes inside each section are ordered newest first,
  • the input array is not modified.

Dates are YYYY-MM-DD strings. The starter has several bugs; run it and compare its output with what the list needs.

const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

const hikes = [
  { id: 'h1', name: 'Ridge Loop', date: '2026-10-04', distanceKm: 8.4 },
  { id: 'h2', name: 'Old Mill Creek', date: '2026-09-12', distanceKm: 5.1 },
  { id: 'h3', name: 'Lake Trail', date: '2026-10-19', distanceKm: 11.2 },
  { id: 'h4', name: 'Eagle Point', date: '2025-10-02', distanceKm: 14.0 },
  { id: 'h5', name: 'Fern Gully', date: '2026-09-28', distanceKm: 3.6 },
];

// Return SectionList sections: [{ title: 'October 2026', data: [...] }, ...]
function groupHikesByMonth(hikes) {
  const sections = {};
  for (const hike of hikes) {
    const month = MONTHS[Number(hike.date.slice(5, 7))];
    if (!sections[month]) sections[month] = [];
    sections[month].push(hike);
  }
  return Object.keys(sections).map((title) => ({ title, data: sections[title] }));
}

console.log(JSON.stringify(groupHikesByMonth(hikes), null, 2));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • Section titles include the year and are ordered newest month first
  • Hikes inside each section are ordered newest first
  • January and December get the right month names
  • The original hikes array keeps its order
  • An empty log gives an empty sections array