Headers, Footers and Empty States

The FlatList slots for content above, below and between rows, and why an empty list is not a loading list.

Syntax<FlatList ... ListHeaderComponent={Header} ListFooterComponent={<Footer count={n} />} ItemSeparatorComponent={Separator} ListEmptyComponent={<Empty />} />

A list is rarely only rows. FlatList gives you three slots for the content around the rows and one for the gap between them, so you do not have to wrap the list in extra scrolling views.

The slots

ListHeaderComponent renders above the first row and ListFooterComponent below the last. Both scroll with the content; neither is pinned. ListEmptyComponent renders where the rows would be when data has no items, with the header and footer still around it. ItemSeparatorComponent renders between rows only, never above the first or below the last.

Each slot accepts an element or a component reference. These two do the same thing:

ListHeaderComponent={<Header />}
ListHeaderComponent={Header}

Pass an element when you need to hand it props. Pass the component itself when it takes none.

Loading is not empty

Before data arrives, data is an empty array, so ListEmptyComponent renders and the reader sees "No results" for a moment before rows appear. These are different states: empty means loaded and there is nothing in it. Track loading separately and decide which of the two to render.

Separators are not CSS borders

A separator is a real component that occupies layout space. There is no border collapsing and no last-child selector to suppress it, because FlatList simply does not draw one at the ends. It receives two props: highlighted and leadingItem. Its height counts towards row height if you later measure rows for getItemLayout.

Example

Example · tsx
import { ActivityIndicator, FlatList, StyleSheet, Text, View } from "react-native";

type Note = { id: string; body: string };

type NoteListProps = {
  notes: Note[];
  status: "loading" | "ready";
};

function Separator() {
  return <View style={styles.separator} />;
}

export default function NoteList({ notes, status }: NoteListProps) {
  return (
    <FlatList
      data={notes}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text style={styles.body}>{item.body}</Text>}
      // an element, because it takes a prop
      ListHeaderComponent={<Text style={styles.heading}>Notes</Text>}
      ListFooterComponent={<Text style={styles.footer}>{notes.length} saved</Text>}
      // a component reference, because it takes none
      ItemSeparatorComponent={Separator}
      // empty and loading are separate states, so branch before rendering either
      ListEmptyComponent={
        status === "loading" ? (
          <ActivityIndicator style={styles.center} />
        ) : (
          <Text style={styles.center}>No notes yet</Text>
        )
      }
      contentContainerStyle={styles.list}
    />
  );
}

const styles = StyleSheet.create({
  list: { paddingVertical: 16 },
  heading: { fontSize: 22, fontWeight: "700", paddingHorizontal: 16, paddingBottom: 8 },
  body: { fontSize: 16, paddingHorizontal: 16, paddingVertical: 12 },
  separator: { height: 1, backgroundColor: "#e2e2e2", marginHorizontal: 16 },
  footer: { fontSize: 13, color: "#666", padding: 16 },
  center: { padding: 32, textAlign: "center", color: "#666" },
});

When to use it

  • Put a title, search field or filter row above a list without nesting it in another scrolling view.
  • Show "No results" only after loading finishes, instead of flashing it on first paint.
  • Draw hairlines between rows without one appearing above the first or below the last.
  • Report paging progress at the bottom of a feed.

More examples

A header that stays on screen

On screen: the title bar sits at the top and stops there as the rows scroll under it. `stickyHeaderIndices` is a ScrollView prop that FlatList passes through, and the list header counts as child 0. Reach for this when a search field or filter row must stay reachable in a long list.

Example · tsx
import { FlatList, StyleSheet, Text, View } from "react-native";

type City = { id: string; name: string };

type CityListProps = { cities: City[] };

function SearchBar() {
  return (
    <View style={styles.bar}>
      <Text style={styles.barText}>Cities</Text>
    </View>
  );
}

export default function CityList({ cities }: CityListProps) {
  return (
    <FlatList
      data={cities}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text style={styles.row}>{item.name}</Text>}
      ListHeaderComponent={SearchBar}
      // FlatList forwards ScrollView props; index 0 is the list header
      stickyHeaderIndices={[0]}
    />
  );
}

const styles = StyleSheet.create({
  // a sticky header needs its own background, or rows scroll visibly behind it
  bar: { backgroundColor: "#fff", padding: 16, borderBottomWidth: 1, borderBottomColor: "#e2e2e2" },
  barText: { fontSize: 18, fontWeight: "600" },
  row: { padding: 16, fontSize: 16 },
});

A separator that reads its neighbours

On screen: a hairline between rows with the previous row's date printed under it. `leadingItem` is the item above the separator, so a separator can label or compare its neighbours - the closest thing here to a CSS adjacent-sibling rule, except you compute it yourself. `highlighted` is toggled by the separators argument that renderItem receives.

Example · tsx
import { FlatList, StyleSheet, Text, View } from "react-native";

type Entry = { id: string; day: string; label: string };

type LogProps = { entries: Entry[] };

// ItemSeparatorComponent is given highlighted and leadingItem
type SeparatorProps = { highlighted: boolean; leadingItem?: Entry };

function DaySeparator({ highlighted, leadingItem }: SeparatorProps) {
  return (
    <View style={[styles.line, highlighted && styles.lineHidden]}>
      <Text style={styles.day}>{leadingItem ? leadingItem.day : ""}</Text>
    </View>
  );
}

export default function Log({ entries }: LogProps) {
  return (
    <FlatList
      data={entries}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text style={styles.row}>{item.label}</Text>}
      ItemSeparatorComponent={DaySeparator}
    />
  );
}

const styles = StyleSheet.create({
  row: { padding: 16, fontSize: 16 },
  line: { borderTopWidth: 1, borderTopColor: "#e2e2e2", paddingHorizontal: 16, paddingTop: 4 },
  lineHidden: { borderTopColor: "transparent" },
  day: { fontSize: 11, color: "#888" },
});

A footer that reports the end of the data

The footer carries three outcomes: a spinner while more rows are on the way, a closing line when there are none left, and nothing at all otherwise. This is the standard place for paging feedback, because the footer sits in the scrolling content where the reader is already looking.

Example · tsx
import { ActivityIndicator, FlatList, StyleSheet, Text } from "react-native";

type Post = { id: string; title: string };

type FeedProps = {
  posts: Post[];
  loadingMore: boolean;
  hasMore: boolean;
};

export default function Feed({ posts, loadingMore, hasMore }: FeedProps) {
  function renderFooter() {
    if (loadingMore) return <ActivityIndicator style={styles.footer} />;
    if (!hasMore && posts.length > 0) return <Text style={styles.footer}>End of feed</Text>;
    return null; // null renders nothing and takes no space
  }

  return (
    <FlatList
      data={posts}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
      ListFooterComponent={renderFooter()}
    />
  );
}

const styles = StyleSheet.create({
  row: { padding: 16, fontSize: 16 },
  footer: { padding: 24, textAlign: "center", color: "#666" },
});

Discussion

  • Be the first to comment on this lesson.