In a React Native ScrollView, how does stickyHeaderIndices pin section headers, and why can it pin the wrong child?
answer
- indices of direct children
- each header wrapped by the ScrollView
- next header pushes the previous one
- false children drop out of the count
- not with horizontal
basics
~20 sstickyHeaderIndices lists the positions of a React Native ScrollView's direct children that dock at the top while scrolling. Positions are counted after null and false children are dropped, so a hidden conditional child shifts every later index.
solid answer
~40 s`stickyHeaderIndices` is an array of positions among the `ScrollView`'s **direct children**; `[0]` docks the first child at the top once the user scrolls past it. React Native wraps each chosen child in a sticky header component (by default `ScrollViewStickyHeader`, replaceable with `StickyHeaderComponent`), and when the next sticky header reaches the top it pushes the previous one out. The index trap: the `ScrollView` flattens its children with `React.Children.toArray`, which drops `null`, `undefined` and boolean entries, so a `{showBanner && <Banner />}` child that is `false` removes a slot and every hard-coded index after it now points one child later. Compute the indices from the same data you render. It is not supported with `horizontal`, `invertStickyHeaders` docks at the bottom instead, and `stickyHeaderHiddenOnScroll` hides a header while scrolling down.
code
tsx · 36 linesimport type {ReactElement} from 'react';
import {ScrollView, StyleSheet, Text, View} from 'react-native';
type Group = {title: string; rows: string[]};
export function SettingsGroups({groups}: {groups: Group[]}) {
const children: ReactElement[] = [];
const stickyHeaderIndices: number[] = [];
for (const group of groups) {
// Record the index from the same array we render: no hard-coded numbers.
stickyHeaderIndices.push(children.length);
children.push(
<Text key={`h-${group.title}`} style={styles.header}>
{group.title}
</Text>,
);
for (const row of group.rows) {
children.push(
<View key={`${group.title}-${row}`} style={styles.row}>
<Text>{row}</Text>
</View>,
);
}
}
return (
<ScrollView stickyHeaderIndices={stickyHeaderIndices}>{children}</ScrollView>
);
}
const styles = StyleSheet.create({
// Opaque background so rows do not show through the pinned header.
header: {backgroundColor: '#ffffff', padding: 12, fontWeight: '600'},
row: {paddingHorizontal: 12, paddingVertical: 14},
});go deeper
Recall that stickyHeaderIndices takes positions of the ScrollView's direct children, and that [0] pins the first child at the top.
Explain the mechanics: children are flattened, chosen ones are wrapped in a sticky header component, and the next header pushes the previous one out. Know that false children drop from the count.
Debug a header that pins the wrong row by checking conditional children and hard-coded indices, and fix it by deriving indices from the rendered data. Know it does not work with horizontal.
Judge when a pinned-header ScrollView should become a SectionList, and keep grouped settings screens on one shared pattern so the index bug cannot reappear screen by screen.
## What the prop does A settings screen is a set of groups — **Account**, **Notifications**, **Privacy** — each with a heading and a few rows. When the user scrolls through a long group, it helps if its heading stays pinned at the top. On a plain `ScrollView`, `stickyHeaderIndices` does this: - it takes an **array of child positions**, for example `[0, 3, 7]`; - each position refers to a **direct child** of the `ScrollView`, not to a nested view; - when that child reaches the top edge during scrolling, it **docks** there; - when the **next** sticky header arrives, it pushes the docked one up and out, so one heading is pinned at a time. `stickyHeaderIndices={[0]}` pins the first child, the example the docs give. ## How it works inside React Native implements sticky headers in the `ScrollView` component itself: 1. It flattens the children into an array with `React.Children.toArray`. 2. For each child whose position appears in `stickyHeaderIndices`, it wraps the child in a sticky header component — `ScrollViewStickyHeader` by default, or your own via the `StickyHeaderComponent` prop. 3. It measures each header's layout and passes each wrapper the layout position of the next header, so it knows when to be pushed away. 4. It attaches an `Animated` value to the native scroll event, and the wrapper translates the header from that value rather than from a JavaScript `onScroll` handler. One side effect follows from this design: while sticky headers are present, React Native overrides `scrollEventThrottle` to `1`, so the scroll events that feed the headers are not throttled. The header positions also depend on measured layouts, which is why each wrapper reports its own `onLayout` back to the `ScrollView`. Because the header is drawn over the rows scrolling beneath it, it needs an **opaque background**; a transparent heading shows the rows through it. ## Why the wrong child gets pinned Step 1 is where the classic bug comes from. `React.Children.toArray` **drops empty entries**: `null`, `undefined` and booleans are omitted from the array. Consider: - children: `{isBeta && <BetaBanner />}`, `<Header title="Account" />`, `<Row />` and more rows; - `stickyHeaderIndices={[1]}`, written while `isBeta` was true; - when `isBeta` is `false`, the first entry disappears from the flattened array, `Header` becomes index 0 and the first `Row` becomes index 1. The first account row now sticks, and the heading scrolls away. The same shift happens when rows are added to or removed from an earlier group while the indices are hard-coded. The robust pattern is to **derive the indices from the same data that produces the children**: walk the groups, record `children.length` before pushing each heading, then push the rows. ## Related props and limits | Prop | Effect | |---|---| | `stickyHeaderIndices` | positions of direct children that dock at the top | | `StickyHeaderComponent` | replaces the default wrapper, for headers with custom transforms | | `invertStickyHeaders` | docks headers at the bottom instead, typically with inverted scroll views | | `stickyHeaderHiddenOnScroll` | hides the docked header while scrolling down and shows it when scrolling up | Limits to state in an interview: - **Not supported with `horizontal={true}`**, per the docs. - On Android, React Native turns off `removeClippedSubviews` for the content container when sticky headers are present, because subview clipping causes issues with them. - It pins children of a **plain `ScrollView`**. Long grouped data belongs in a `SectionList`, which has its own section-header behaviour. ## Common mistakes - Hard-coding indices next to conditional children. - Pointing an index at a child nested inside a wrapper `View`, which cannot work because only direct children are candidates. - Transparent header backgrounds. - Using a `ScrollView` with sticky headers for a long server-driven list that should have been a `SectionList`.
- Can you make a heading sticky if it is wrapped in a View together with its rows?No. `stickyHeaderIndices` only considers the `ScrollView`'s direct children. If a heading and its rows share one wrapper `View`, the whole group is a single child, and pinning it would pin the entire group. Flatten the heading and rows into separate direct children, or use a `SectionList` for grouped data.
- When would you pass a custom StickyHeaderComponent?When the pinned header needs its own transforms — for example an animated header that hides itself — the default `ScrollViewStickyHeader` wrapper may not fit. The docs suggest supplying your own sticky header component in that case, used together with `stickyHeaderIndices`.
saying these in an interview costs you the question
- stickyHeaderIndices can point at any nested view inside the ScrollView.
- A child that renders false still occupies its index in stickyHeaderIndices.
- stickyHeaderIndices works the same on a horizontal ScrollView.
- Several sticky headers stay stacked at the top at the same time.
- Sticky headers look right with a transparent background.