Guides

Practical recipes for common Legend List use cases.

Chat Interfaces

Use this when your messages should start at the bottom without using inverted.

alignItemsAtEnd?: boolean;
maintainScrollAtEnd?: boolean;
maintainScrollAtEndThreshold?: number;
<LegendList
  data={items}
  renderItem={({ item }) => <Text>{item.title}</Text>}
  estimatedItemSize={320}
  alignItemsAtEnd
  maintainScrollAtEnd
  maintainScrollAtEndThreshold={0.1}
/>

Pitfalls:

  • Avoid inverted; it can cause animation and scroll edge cases.
  • Tune maintainScrollAtEndThreshold for your UX.

Initial Positioning

Use declarative initial scroll props when the first viewport should start at a specific item or at the end of the list.

initialScrollAtEnd?: boolean;
initialScrollIndex?: number | { index: number; viewOffset?: number; viewPosition?: number };
initialScrollOffset?: number;
<LegendList
  data={messages}
  keyExtractor={(item) => item.id}
  renderItem={renderMessage}
  estimatedItemSize={72}
  initialScrollIndex={{ index: highlightedIndex, viewPosition: 0.5 }}
/>

For chat and timeline screens, prefer initialScrollAtEnd over calling scrollToEnd after mount.

<LegendList
  data={messages}
  keyExtractor={(item) => item.id}
  renderItem={renderMessage}
  estimatedItemSize={72}
  initialScrollAtEnd
  maintainScrollAtEnd
/>

Pitfalls:

  • Prefer initialScrollIndex, initialScrollOffset, or initialScrollAtEnd for initial placement instead of imperative scroll calls in useEffect.
  • Provide getFixedItemSize when target rows have exact fixed sizes. Use estimatedItemSize only as a rough initial offset hint when dynamic rows are significantly different from 100px.
  • Use stable keys so measurement caches can survive data refreshes.

Floating Composer / Overlay Insets

Use this when a composer or input bar visually covers the end of the list but should stay outside the list's normal content.

contentInsetEndAdjustment?: number;
anchoredEndSpace?: {
  anchorIndex: number;
  anchorOffset?: number;
  anchorMaxSize?: number;
  onSizeChanged?: (size: number) => void;
};

On web, measure the overlay and pass that height to contentInsetEndAdjustment.

<LegendList
  data={messages}
  renderItem={renderMessage}
  keyExtractor={(item) => item.id}
  initialScrollAtEnd
  maintainScrollAtEnd
  anchoredEndSpace={sentIndex !== undefined ? { anchorIndex: sentIndex } : undefined}
  contentInsetEndAdjustment={composerHeight + 24}
/>

On React Native, use the keyboard-aware integration and pass the shared value returned by useKeyboardChatComposerInset.

const listRef = useRef<LegendListRef>(null);
const composerRef = useRef<View>(null);
const { contentInsetEndAdjustment, onComposerLayout } =
  useKeyboardChatComposerInset(listRef, composerRef);

<KeyboardAwareLegendList
  ref={listRef}
  data={messages}
  renderItem={renderMessage}
  keyExtractor={(item) => item.id}
  initialScrollAtEnd
  contentInsetEndAdjustment={contentInsetEndAdjustment}
/>

<KeyboardStickyView>
  <View ref={composerRef} onLayout={onComposerLayout}>
    <Composer />
  </View>
</KeyboardStickyView>

Pitfalls:

  • Prefer contentInsetEndAdjustment over padding the list content when the overlay size changes dynamically.
  • Use anchoredEndSpace for the row you want to land near the start after sending.
  • Keep a stable keyExtractor; changing keys while adjusting overlay inset will discard size and position caches.

Web Layout and Window Scroll

Use the React entrypoint for DOM-native lists:

import { LegendList } from "@legendapp/list/react";

For contained lists, the scroll container needs a real height.

<div style={{ height: 480, minHeight: 0 }}>
  <LegendList
    data={items}
    keyExtractor={(item) => item.id}
    renderItem={renderItem}
    style={{ height: "100%" }}
  />
</div>

For pages that already scroll at the document level, use useWindowScroll.

<LegendList
  data={items}
  keyExtractor={(item) => item.id}
  renderItem={renderItem}
  useWindowScroll
/>

Pitfalls:

  • In flex layouts, make sure parent containers can shrink, usually with minHeight: 0.
  • Use contentContainerStyle for item spacing such as gap; gap-* classes on contentContainerClassName do not control virtualized item spacing.
  • Use the React Native entrypoint instead if your app renders through React Native Web.

Infinite Scrolling

Use onEndReached for standard feeds and onStartReached for prepending older items.

onStartReached?: ((info: { distanceFromStart: number }) => void) | null | undefined;
onStartReachedThreshold?: number;
onEndReached?: ((info: { distanceFromEnd: number }) => void) | null | undefined;
onEndReachedThreshold?: number;
<LegendList
  data={data}
  renderItem={({ item }) => <MessageItem item={item} />}
  keyExtractor={(item) => item.id}
  onEndReached={loadMoreAtEnd}
  onStartReached={loadMoreAtStart}
  onEndReachedThreshold={0.5}
  onStartReachedThreshold={0.5}
  maintainVisibleContentPosition={{ data: true }}
  recycleItems
/>

Pitfalls:

  • Guard against duplicate loads (loading state or request dedupe).
  • For prepend flows, keep maintainVisibleContentPosition={{ data: true }}.

Snap to Indices

Use snapToIndices when specific rows should become scroll snap points.

<LegendList
  data={sections}
  estimatedItemSize={320}
  getFixedItemSize={(item) => item.height}
  keyExtractor={(item) => item.id}
  renderItem={renderSection}
  snapToIndices={[0, 4, 8, 12]}
/>

On web, snap indices can point to items outside the currently mounted DOM window. LegendList computes snap offsets from list measurements and estimates, so virtualization does not require every snap target to be mounted.

Pitfalls:

  • Use getFixedItemSize for exact snap positions when item sizes are fixed.
  • For dynamic rows, estimatedItemSize only improves the initial snap offset before rows are measured. It is usually worth setting only when rows are far from the default 100px.
  • Keep snap indices within the data range and update them when the data shape changes.

Always Render

Use alwaysRender to keep specific rows mounted outside the virtualized window.

<LegendList
  data={data}
  keyExtractor={(item) => item.id}
  estimatedItemSize={48}
  alwaysRender={{ top: 2, bottom: 2 }}
  renderItem={({ item, index }) => (
    <Row label={item.title} pinned={index < 2 || index >= data.length - 2} />
  )}
/>

alwaysRender accepts:

  • top / bottom: keep first/last N items mounted
  • indices: keep explicit indices mounted
  • keys: keep specific keys mounted (requires keyExtractor)

Maintain Visible Content Position

Use this when data or size changes above the viewport should not move what the user is reading.

maintainVisibleContentPosition?:
  | boolean
  | {
      data?: boolean;
      size?: boolean;
      shouldRestorePosition?: (item: ItemT, index: number, data: ItemT[]) => boolean;
    };

Defaults:

  • size: true stabilizes scroll during size/layout changes
  • data: false does not anchor on data changes unless enabled

Common setup for prepend-heavy feeds:

<LegendList
  data={messages}
  maintainVisibleContentPosition={{ data: true, size: true }}
  onStartReached={loadOlderMessages}
/>

Item Size Hints

LegendList measures dynamic items automatically, so size props are optional. In v3, estimatedItemSize is mostly just an initial container allocation hint before measurement. The default estimate is 100px, and after rows render LegendList uses measured sizes and averages.

Only set estimatedItemSize when most rows are significantly larger or smaller than 100px, or when a far initial scroll / snap target needs a better first offset.

<LegendList
  data={items}
  estimatedItemSize={88}
  getItemType={(item) => item.kind}
  keyExtractor={(item) => item.id}
  renderItem={renderItem}
/>

Use getFixedItemSize when the rendered size is exact. This is a stronger optimization than estimatedItemSize because those rows do not need measurement.

<LegendList
  data={rows}
  getFixedItemSize={(item) => (item.kind === "header" ? 48 : 72)}
  keyExtractor={(item) => item.id}
  renderItem={renderRow}
/>

To inspect measured averages after rows render, use getState().getAverageItemSizes().

const averages = listRef.current?.getState().getAverageItemSizes();

Pitfalls:

  • Use getItemType when item families have meaningfully different average sizes.
  • Do not tune estimatedItemSize just to match measured averages closely; it mostly affects initial container count.

SectionList patterns

For grouped data with headers/footers per section, use SectionList.

import { SectionList } from "@legendapp/list/section-list";

<SectionList
  sections={sections}
  keyExtractor={(item) => item.id}
  renderSectionHeader={({ section }) => <Header title={section.title} />}
  renderItem={({ item }) => <Row item={item} />}
  stickySectionHeadersEnabled
  estimatedItemSize={48}
/>

For full prop and method details (including scrollToLocation), see API Reference.

On this page