Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 8 min read

React Native FlatList: The Props That Matter (2026)

Nearly every mobile app is a list of things and a detail screen for one of them. That makes FlatListthe component you’ll write more times than any other — and the one where a small mistake shows up as the whole app feeling cheap. This is the working reference: the props that earn their place, the three things that actually cause jank, pull-to-refresh and infinite scroll done once and correctly, and an honest answer on FlashList.

Why not just map over an array?

Because .map() inside a ScrollView mounts every single row before the user sees anything. Twenty rows is fine. Two thousand rows is a frozen screen and, on Android, sometimes a crash. FlatList is virtualized: it renders what fits plus a buffer, and drops rows as they leave the window.

The corollary is the rule people break most often: never nest a FlatList inside a ScrollViewalong the same scroll axis. The ScrollView hands the list unbounded height, the list concludes everything is visible, and virtualization silently turns itself off — you get the performance of a map with the complexity of a list. If you need content above the list, that’s what ListHeaderComponent is for.

The props worth knowing

PropWhat it doesWhat you get without it
keyExtractorStable identity per rowRows re-mount and lose state on reorder
ListEmptyComponentWhat shows when data is emptyA blank screen users read as broken
ItemSeparatorComponentDivider between rows onlyA stray line after the last row
ListHeaderComponentSearch bar, filters, statsA header that will not scroll away
refreshing / onRefreshNative pull-to-refreshUsers pull anyway and nothing happens
onEndReachedLoad the next pageManual "load more" buttons
getItemLayoutSkip measurement for fixed-height rowsSlower scrollToIndex on long lists
initialNumToRenderRows drawn on first paintA slower first frame than you need

That’s the whole list. The tuning props people reach for first — windowSize, maxToRenderPerBatch, removeClippedSubviews — are last resorts, and setting them blindly usually trades blank space during scrolling for a marginally faster mount. Fix rendering first.

A complete list screen

Search header, empty state, pull-to-refresh, and pagination — the four things a real list screen always grows:

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

// 1. The row lives outside the screen and is memoized.
//    Inline row components re-render on every parent state change.
const JobRow = memo(function JobRow({ job, onPress }) {
  return (
    <Pressable style={styles.row} onPress={() => onPress(job.id)}>
      <Text style={styles.title} numberOfLines={1}>{job.title}</Text>
      <Text style={styles.meta}>{job.company} · {job.location}</Text>
    </Pressable>
  );
});

export default function JobsScreen({ navigation }) {
  const [jobs, setJobs] = useState([]);
  const [refreshing, setRefreshing] = useState(false);
  const [loadingMore, setLoadingMore] = useState(false);
  const [page, setPage] = useState(1);
  const [reachedEnd, setReachedEnd] = useState(false);

  const openJob = useCallback((id) => navigation.navigate('Job', { id }), [navigation]);

  // 2. Stable function identity — not an inline arrow in the JSX
  const renderItem = useCallback(
    ({ item }) => <JobRow job={item} onPress={openJob} />,
    [openJob],
  );

  const onRefresh = useCallback(async () => {
    setRefreshing(true);
    const first = await api.jobs({ page: 1 });
    setJobs(first);
    setPage(1);
    setReachedEnd(false);
    setRefreshing(false);
  }, []);

  const loadMore = useCallback(async () => {
    // 3. The guard: onEndReached fires repeatedly while scrolling
    if (loadingMore || refreshing || reachedEnd) return;
    setLoadingMore(true);
    const next = await api.jobs({ page: page + 1 });
    if (next.length === 0) setReachedEnd(true);
    setJobs((prev) => [...prev, ...next]);
    setPage((p) => p + 1);
    setLoadingMore(false);
  }, [loadingMore, refreshing, reachedEnd, page]);

  return (
    <FlatList
      data={jobs}
      renderItem={renderItem}
      keyExtractor={(item) => item.id}          // 4. id, never the index
      ItemSeparatorComponent={() => <View style={styles.separator} />}
      ListHeaderComponent={<SearchBar onQuery={onRefresh} />}
      ListEmptyComponent={
        <View style={styles.empty}>
          <Text style={styles.emptyText}>
            {refreshing ? 'Loading jobs…' : 'No jobs match that search yet.'}
          </Text>
        </View>
      }
      ListFooterComponent={loadingMore ? <Spinner /> : null}
      refreshing={refreshing}
      onRefresh={onRefresh}
      onEndReached={loadMore}
      onEndReachedThreshold={0.5}
      contentContainerStyle={jobs.length === 0 && { flexGrow: 1 }}
    />
  );
}

const styles = StyleSheet.create({
  row: { paddingVertical: 14, paddingHorizontal: 16, gap: 4 },
  title: { color: '#fff', fontSize: 16, fontWeight: '600' },
  meta: { color: 'rgba(255,255,255,0.6)', fontSize: 13 },
  separator: { height: 1, backgroundColor: 'rgba(255,255,255,0.08)' },
  empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
  emptyText: { color: 'rgba(255,255,255,0.6)', textAlign: 'center' },
});

The last line is a detail worth stealing: contentContainerStyle with flexGrow: 1only while empty. Without it your empty state clings to the top of the screen instead of centring, which is the difference between “designed” and “unfinished” on an otherwise identical screen.

The three real causes of jank

  1. Rows that re-render for no reason. An inline renderItem={({ item }) => <Row … />} is a new function every render, so every visible row rebuilds every time any state on the screen changes — including the search text you’re typing. Memoize the row, and give renderItem a stable identity with useCallback.
  2. Full-size images in thumbnails. A 4000px JPEG decoded into a 64px avatar costs real memory and a frame. Serve resized images from your backend or CDN — this is usually a bigger win than every list prop combined.
  3. Index as key. keyExtractor={(_, i) => String(i)}works right up until the data reorders or an item is deleted, at which point rows keep the wrong state — a checkbox stays ticked on the row that replaced it. Use the record’s id.

Measure before and after in release mode on the cheapest Android you have. Dev builds run unoptimised JavaScript, so a list that stutters in Expo Go may well be fine in production — and one that’s smooth on your iPhone can still be unusable on a mid-range Android.

When to switch to FlashList

@shopify/flash-list is a drop-in-shaped replacement that recycles row views instead of unmounting and remounting them. On long lists with rich rows — a feed, a chat, a product catalogue — it holds up noticeably better during fast scrolling on Android, where FlatList is likelier to show blank space:

npx expo install @shopify/flash-list

import { FlashList } from '@shopify/flash-list';

<FlashList data={jobs} renderItem={renderItem} keyExtractor={(i) => i.id} />

It’s a native module, so it needs a development build rather than Expo Go — the same one-time cost as any other native dependency. The honest guidance: start with FlatList, and switch when a specific screen measurably struggles. Adding a dependency for a hundred-row settings list is optimisation theatre.

The shortcut: generate the screen, keep the judgement

A list screen is the same twelve props every time, wrapped around a row layout that’s different every time. The props are memorised work; the row — what the user actually reads at a glance — is design.

Describe the screen in ShipNative — “a feed of local jobs with a search bar, pull-to-refresh and infinite scroll” — and it generates the FlatList with memoized rows, an empty state and pagination already wired, running on your phone so you can scroll it with a thumb. Then export the whole Expo project and keep the code.

Build your list app free

Describe your app in one sentence and scroll it on your own phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.

Frequently Asked Questions

What is the difference between FlatList and ScrollView in React Native?

A ScrollView renders every child immediately, so a thousand rows means a thousand mounted components and a frozen screen. A FlatList is virtualized: it renders roughly what fits on screen plus a buffer, and recycles as you scroll. Use a ScrollView for a short, fixed set of content such as a settings screen, and a FlatList for anything list-shaped that can grow.

Why is my FlatList slow or janky?

Three usual causes. The row component re-renders on every parent render because renderItem is an inline arrow function and the row is not memoized. Rows contain large unresized images. Or the list is nested inside a ScrollView, which destroys virtualization entirely because the outer view gives the list unbounded height. Fix those three before touching any tuning props.

Should I use FlashList instead of FlatList?

For long, complex, or image-heavy lists, yes — FlashList from Shopify recycles views rather than unmounting them, which noticeably reduces blank space during fast scrolling on Android. For a list of a hundred simple rows the difference is not worth a dependency. FlatList ships with React Native and is fine until it measurably is not.

How do I add infinite scroll to a FlatList?

Use onEndReached with onEndReachedThreshold of about 0.5, and guard it with a loading flag — the callback can fire more than once as the user keeps scrolling, and without a guard you will fetch page two three times. Render a footer spinner while the next page loads.

Can an AI app builder generate a proper list screen?

Yes, and lists are one of the highest-value things to generate because the same twelve props appear in every list screen you will ever write. Describe the screen — "a feed of jobs with a search bar, pull-to-refresh, and infinite scroll" — and ShipNative wires the FlatList with memoized rows, empty state, and pagination, then previews it on your phone where scroll performance is actually visible.

→

10 React Native Performance Fixes

Lists are one chapter — images, re-renders, and startup are the rest.

Read guide →
→

React Native Animations with Reanimated

Row entrance and layout animations that survive a fast scroll.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.