SectionList
Rendering grouped data with section headers, and the exact shape SectionList expects its sections in.
<SectionList sections={[{ title: 'A', data: items }]} renderSectionHeader={({ section }) => <Header title={section.title} />} renderItem={({ item }) => <Row item={item} />} />SectionList is FlatList for grouped data: contacts under letters, events under dates, settings under category titles. It virtualises the same way, and adds headers and footers per group.
The shape it expects
The sections prop is an array of objects, each with a required data array of its items. Any other field is yours - title is only a convention. A section may also carry its own key, renderItem, keyExtractor and ItemSeparatorComponent, which override the list-level ones for that group.
renderItem receives { item, index, section, separators }, so a row can read the group it belongs to. renderSectionHeader and renderSectionFooter each receive { section } and nothing else: no index, no item count beyond section.data.length.
Sticky headers differ by platform
stickySectionHeadersEnabled defaults to true on iOS and false on Android. Set it explicitly if you want the same behaviour on both. The bare attribute is the shorthand for the explicit value - these are identical:
stickySectionHeadersEnabled
stickySectionHeadersEnabled={true}
What trips people up
A sticky header has no background of its own, so rows scroll visibly through it. Give it a backgroundColor; this is not a CSS stacking context and zIndex will not save you.
keyExtractor only has to be unique within a section. SectionList builds each item's key by prefixing the section's key, so two sections that both contain an item with id 1 do not collide. What does need its own identity is the section: give each one a key if you ever reorder them, or the array index is used.
Example
import { SectionList, StyleSheet, Text, View } from "react-native";
type Contact = { id: string; name: string };
type ContactSection = { title: string; data: Contact[] };
const SECTIONS: ContactSection[] = [
{
title: "A",
data: [
{ id: "1", name: "Ada" },
{ id: "2", name: "Amin" },
],
},
{ title: "B", data: [{ id: "3", name: "Bea" }] },
];
export default function ContactList() {
return (
<SectionList
sections={SECTIONS}
// only has to be unique within a section: the section's key is prefixed for you
keyExtractor={(item) => item.id}
renderItem={({ item, section }) => (
<Text style={styles.name}>
{item.name} ({section.title})
</Text>
)}
// receives { section } only
renderSectionHeader={({ section }) => (
<View style={styles.header}>
<Text style={styles.headerText}>{section.title}</Text>
</View>
)}
// true on iOS already, false on Android, so state it
stickySectionHeadersEnabled
/>
);
}
const styles = StyleSheet.create({
name: { fontSize: 16, paddingHorizontal: 16, paddingVertical: 12 },
// the background is required: without it rows scroll through the sticky header
header: { backgroundColor: "#f2f2f2", paddingHorizontal: 16, paddingVertical: 6 },
headerText: { fontSize: 13, fontWeight: "700", color: "#444" },
});When to use it
- Show contacts grouped under their initial letter with the letter pinned while scrolling.
- Build a settings screen where each group has a heading and a caution note underneath.
- Render an agenda grouped by day, with days that have no events removed.
- Give one group of rows a different row renderer without branching inside the main one.
More examples
Building sections from a flat array
Data almost never arrives grouped, so this is the function you write before the SectionList. Keep it outside the component, or wrap the call in useMemo - rebuilding the array on every render hands SectionList a new `sections` prop each time and defeats its re-render checks. The second output shows that grouping preserves the input order inside each bucket; sort the items too if you want them alphabetical.
type Contact = { id: string; name: string };
type ContactSection = { title: string; data: Contact[] };
function groupByInitial(contacts: Contact[]): ContactSection[] {
const buckets = new Map<string, Contact[]>();
for (const contact of contacts) {
const title = contact.name[0].toUpperCase();
// ?? is the same as: existing === null || existing === undefined ? [] : existing
const bucket = buckets.get(title) ?? [];
bucket.push(contact);
buckets.set(title, bucket);
}
return [...buckets.keys()]
.sort()
.map((title) => ({ title, data: buckets.get(title) as Contact[] }));
}
const sections = groupByInitial([
{ id: "2", name: "amin" },
{ id: "1", name: "Ada" },
{ id: "3", name: "Bea" },
]);
console.log(sections.map((s) => s.title));
// Output: [ 'A', 'B' ]
console.log(sections[0].data.map((c) => c.name));
// Output: [ 'amin', 'Ada' ]Section footers and per-section overrides
On screen: a settings page where the Danger group renders its single row in red and prints a caution line beneath it. A section-level `renderItem` replaces the list-level one for that group, which is cleaner than branching inside one renderer. Note that `SectionSeparatorComponent` renders at the top and bottom of every section, unlike `ItemSeparatorComponent`, which only goes between items.
import { SectionList, StyleSheet, Text, View } from "react-native";
type Setting = { id: string; label: string };
type SettingSection = {
title: string;
note?: string;
data: Setting[];
renderItem?: ({ item }: { item: Setting }) => React.JSX.Element;
};
const SECTIONS: SettingSection[] = [
{ title: "Account", data: [{ id: "a1", label: "Email" }] },
{
title: "Danger",
note: "These cannot be undone",
data: [{ id: "d1", label: "Delete account" }],
// overrides the list-level renderItem for this section only
renderItem: ({ item }) => <Text style={styles.danger}>{item.label}</Text>,
},
];
export default function SettingsList() {
return (
<SectionList
sections={SECTIONS}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text style={styles.row}>{item.label}</Text>}
renderSectionHeader={({ section }) => <Text style={styles.header}>{section.title}</Text>}
renderSectionFooter={({ section }) =>
section.note ? <Text style={styles.note}>{section.note}</Text> : null
}
SectionSeparatorComponent={() => <View style={styles.gap} />}
/>
);
}
const styles = StyleSheet.create({
row: { fontSize: 16, padding: 16 },
danger: { fontSize: 16, padding: 16, color: "#b00020" },
header: { fontSize: 13, fontWeight: "700", paddingHorizontal: 16, paddingTop: 16 },
note: { fontSize: 12, color: "#666", paddingHorizontal: 16, paddingBottom: 8 },
gap: { height: 8 },
});An empty section list
Two separate emptiness problems. A section with no items still draws its header, so filter those out yourself. `ListEmptyComponent` only appears when there are no sections left at all - which, as with FlatList, happens on the first frame too, hence the loading branch.
import { SectionList, StyleSheet, Text } from "react-native";
type Event = { id: string; name: string };
type DaySection = { title: string; data: Event[] };
type AgendaProps = {
sections: DaySection[];
status: "loading" | "ready";
};
export default function Agenda({ sections, status }: AgendaProps) {
// drop days with nothing in them, or their headers render alone
const filled = sections.filter((section) => section.data.length > 0);
return (
<SectionList
sections={filled}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text style={styles.row}>{item.name}</Text>}
renderSectionHeader={({ section }) => <Text style={styles.header}>{section.title}</Text>}
// runs when every section is gone, not when one section is empty
ListEmptyComponent={
<Text style={styles.empty}>{status === "loading" ? "Loading" : "Nothing scheduled"}</Text>
}
/>
);
}
const styles = StyleSheet.create({
row: { fontSize: 16, padding: 16 },
header: { fontSize: 13, fontWeight: "700", backgroundColor: "#f2f2f2", padding: 8 },
empty: { padding: 32, textAlign: "center", color: "#666" },
});
Discussion