skip to content

Why can scrollToIndex on a React Native FlatList throw or fail for a far-away row, and how do you make it reliable?

level: seniorimportance: should knowfreq 35%

answer

  1. unrendered rows have no measurements
  2. getItemLayout or onScrollToIndexFailed
  3. averageItemLength in the failure info
  4. scroll near, then retry
  5. initialScrollIndex runs once after layout

basics

~20 s

FlatList only knows the offsets of rows it has measured. scrollToIndex to an unmeasured row throws unless getItemLayout or onScrollToIndexFailed is set; the failure callback lets you scroll near the row and retry once it renders.

solid answer

~40 s

`scrollToIndex({index, animated, viewPosition, viewOffset})` has to turn an index into a pixel offset. With `getItemLayout`, the list computes it; without it, the list only knows rows it has already measured. For an index beyond the highest measured cell, it throws `scrollToIndex should be used in conjunction with getItemLayout or onScrollToIndexFailed…` — unless you pass `onScrollToIndexFailed`, which receives `{index, highestMeasuredFrameIndex, averageItemLength}`. The usual recovery for a contacts list jumping to letter M: `scrollToOffset` to `index * averageItemLength`, then retry `scrollToIndex` once the rows there have rendered. Out-of-range indices always throw. `viewPosition` places the row at the top (0), middle (0.5) or bottom (1). `initialScrollIndex` uses the same machinery once, after the content is laid out, and the docs say it requires `getItemLayout`.

code

tsx · 44 lines
tsx
import {useRef} from 'react';
import {FlatList, Text} from 'react-native';

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

export function ContactsWithIndex({contacts}: {contacts: Contact[]}) {
  const listRef = useRef<FlatList<Contact>>(null);
  const retries = useRef(0);

  const jumpToLetter = (letter: string) => {
    const index = contacts.findIndex(c => c.name.startsWith(letter));
    if (index >= 0) {
      retries.current = 0;
      listRef.current?.scrollToIndex({index, animated: true, viewPosition: 0});
    }
  };

  return (
    <FlatList
      ref={listRef}
      data={contacts}
      keyExtractor={c => c.id}
      renderItem={({item}) => <Text style={{padding: 12}}>{item.name}</Text>}
      onScrollToIndexFailed={info => {
        // Land near the row using the measured average, then retry.
        listRef.current?.scrollToOffset({
          offset: info.index * info.averageItemLength,
          animated: false,
        });
        if (retries.current < 3) {
          retries.current += 1;
          setTimeout(() => {
            listRef.current?.scrollToIndex({index: info.index, animated: true});
          }, 100);
        }
      }}
      ListHeaderComponent={
        <Text onPress={() => jumpToLetter('M')} style={{padding: 12}}>
          Jump to M
        </Text>
      }
    />
  );
}

go deeper

for a junior

Know that scrollToIndex needs either getItemLayout or onScrollToIndexFailed for rows that have not been rendered yet, and that indices must be within the data.

for a middle

Explain the mechanism: the list turns an index into an offset from getItemLayout or from measured cells, and throws or calls the failure callback when it has neither. Know viewPosition and viewOffset.

for a senior

Implement a robust jump for variable-height rows: scroll near by averageItemLength, retry after rendering with a cap, and account for multi-column row indices and initialScrollIndex's requirements.

for a principal

Weigh fixed row heights, which make every index addressable, against content-driven heights that need retry logic, when designing list-heavy screens with jump navigation.

## The scenario A 300-row contacts list has an alphabet index on the right. Tapping **M** should jump to the first contact starting with M, which is row 180. The list has rendered only the first screenful or two. The call is: ```tsx listRef.current?.scrollToIndex({index: 180, animated: true}); ``` It throws an invariant error instead of scrolling. ## Why it fails `FlatList` forwards `scrollToIndex` to the underlying `VirtualizedList`, which must convert the index into a content offset. It has two sources of truth: 1. **`getItemLayout`**, if you provided it: a function returning `{length, offset, index}` for any index, so offsets are known without rendering. 2. **Measured cells**: the list records the layout of every cell it has rendered in a metrics cache. Without `getItemLayout`, a row that has never been rendered has no measurement. The implementation checks for exactly this: if `getItemLayout` is missing and the index is beyond the highest measured cell, then: - if `onScrollToIndexFailed` is **not** set, it throws: `scrollToIndex should be used in conjunction with getItemLayout or onScrollToIndexFailed, otherwise there is no way to know the location of offscreen indices or handle failures.`; - if it **is** set, it calls it with `{index, highestMeasuredFrameIndex, averageItemLength}` and returns without scrolling. Independently, the index must be in range: a negative index, an empty list or an index past the end throw `scrollToIndex out of range…` regardless of the other props. ## Making it reliable | Approach | When it fits | |---|---| | `getItemLayout` | rows have a known, fixed size (including separators) | | `onScrollToIndexFailed` with scroll-and-retry | rows vary in height or are measured at runtime | | `scrollToOffset` with your own offset | you already store offsets, for example from section headers | The prop's own documentation gives the recommended action: "either compute your own offset and `scrollTo` it, or scroll as far as possible and then try again after more items have been rendered". A common implementation: 1. in `onScrollToIndexFailed`, call `scrollToOffset({offset: info.index * info.averageItemLength, animated: false})` to land near the target; 2. once the rows around that offset have rendered (a short timeout or the next layout), call `scrollToIndex` again; 3. cap the retries so a list that never measures the row cannot loop. `getItemLayout` itself, and its interaction with windowing, is a tuning topic of its own; here it matters as the prop that makes every index addressable. ## Positioning the target - **`viewPosition`**: `0` puts the row at the top, `1` at the bottom, `0.5` in the middle. - **`viewOffset`**: a fixed number of pixels subtracted from the target, for example to leave room for a sticky search bar overlapping the list. - **`animated`**: defaults to `true`. - **`scrollToItem`** takes an item instead of an index but does a linear scan of `data`; prefer `scrollToIndex`. ## initialScrollIndex `initialScrollIndex` opens the list at a row instead of the top — for example, reopening the contacts list at the contact the user last viewed. Things to know: - after the content has been laid out, the list scrolls to that index once, without animation, using the same `scrollToIndex` path; - it disables the optimisation that keeps the first `initialNumToRender` rows always rendered and instead renders from the initial index; - the docs state it **requires `getItemLayout`**; without it the jump depends on measurements that do not exist yet; - an index outside the data logs a warning that it is not valid. ## Common mistakes - Calling `scrollToIndex` without either `getItemLayout` or `onScrollToIndexFailed`. - Retrying forever in `onScrollToIndexFailed`. - Forgetting separator heights in offsets you compute yourself. - Passing an item index in a multi-column list, where indices count rows.

  • Why does scrollToIndex work for row 5 but fail for row 180 in the same list?
    Row 5 has been rendered, so the list has its measured offset. Row 180 has never been rendered, and without `getItemLayout` the list has no offset for it: it throws, or calls `onScrollToIndexFailed` if you provided it. The difference is measurement, not distance as such.
  • When does getItemLayout make the failure callback unnecessary?
    When every row's size is known up front, for example fixed-height contact rows plus a fixed separator. The list then computes any offset directly and never needs to have rendered the target. Variable-height rows cannot supply accurate layouts, which is when scroll-and-retry is needed.

saying these in an interview costs you the question

  • scrollToIndex works for any index as soon as the list mounts.
  • onScrollToIndexFailed fires only for indices past the end of data.
  • initialScrollIndex works reliably without getItemLayout for variable rows.
  • viewPosition 1 puts the target row at the top of the viewport.
  • Retrying scrollToIndex in a loop until it succeeds is safe.