Guides

Practical recipes for common Legend List use cases.

Chat Interfaces

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

New in 3.7: Use maintainScrollAtEnd.on.dataAppend to follow new messages while leaving prepends and other data edits out of that trigger. Provide a stable keyExtractor: every existing key must remain in order at the start of the new array. Without it, keys are indices, so any increase in data length, including a prepend, triggers following. Leave dataChange disabled for append-only following.

The parent in this example owns messages: append incoming messages with an immutable update, and implement onPrepend to insert the fetched older messages at the start. Keep message IDs stable across updates.

import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Text, View } from "react-native";
import { LegendList, type LegendListRef } from "@legendapp/list/react-native";

type Message = { id: string; text: string; replyToId?: string };
type ChatHistoryProps = {
  messages: Message[];
  fetchOlder: (beforeId: string | undefined) => Promise<Message[]>;
  onPrepend: (older: Message[]) => void;
};

const followNewMessages = {
  on: { dataAppend: true, itemLayout: true, footerLayout: true, layout: true },
};
const messageKey = (message: Message) => message.id;

export function ChatHistory({ messages, fetchOlder, onPrepend }: ChatHistoryProps) {
  const listRef = useRef<LegendListRef>(null);
  const [isFollowingEnd, setIsFollowingEnd] = useState(false);

  useEffect(() => {
    const state = listRef.current!.getState();
    setIsFollowingEnd(state.isFollowingEnd);
    return state.listen("isFollowingEnd", setIsFollowingEnd);
  }, []);

  const renderMessage = useCallback(({ item }: { item: Message }) => {
    const replyToId = item.replyToId;
    return (
      <View>
        <Text>{item.text}</Text>
        {replyToId && (
          <Button
            title="View quoted message"
            onPress={() => listRef.current?.scrollToKey({
              key: replyToId,
              animated: true,
              viewPosition: 0.5,
            })}
          />
        )}
      </View>
    );
  }, []);

  const loadOlder = async () => {
    const older = await fetchOlder(messages[0]?.id);
    // Capture the reader's current position just before applying the prepend.
    listRef.current?.anchorVisibleContent();
    onPrepend(older);
  };

  return (
    <View style={{ flex: 1 }}>
      <Button title="Load older messages" onPress={loadOlder} />
      <LegendList
        ref={listRef}
        style={{ flex: 1 }}
        data={messages}
        keyExtractor={messageKey}
        initialScrollAtEnd
        alignItemsAtEnd
        maintainScrollAtEnd={followNewMessages}
        renderItem={renderMessage}
      />
      {!isFollowingEnd && (
        <Button
          title="Jump to latest"
          onPress={() => listRef.current?.scrollToEnd({ animated: true })}
        />
      )}
    </View>
  );
}

isFollowingEnd reports end-follow intent, including retained intent while maintenance is paused; it is not just a proximity check. Subscribe with getState().listen("isFollowingEnd", ...) and return its unsubscribe function from the effect. distanceFromEnd is a snapshot, not a listen channel.

scrollToKey keeps a quoted-message target attached to its stable key even if older messages are prepended while scrolling. A missing or removed key settles the promise without reaching the item. Scroll promises also resolve when interrupted or canceled, so awaiting them does not guarantee arrival.

anchorVisibleContent() holds the first visible item's key and viewport offset through layout changes. Call it before applying a prepend or expanding content above the reader. Pass { key: messageId } to choose an item, or { hold: false } to inspect the anchor without changing scrolling. It can return undefined before initial placement, during dataset replacement, or when no item is found. At the end while following, it returns the anchor without interrupting following. Otherwise, holding the anchor interrupts maintainScrollAtEnd, including retained intent while it is paused.

For a floating composer and keyboard dismissal, see Floating composer / overlay insets and useKeyboardScrollToEnd.

Pitfalls:

  • Avoid inverted; it can cause animation and scroll edge cases.
  • Tune maintainScrollAtEndThreshold for your UX.
  • Use stable keys rather than indices when messages can be prepended.

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;
  viewPositionFallback?: "start" | "end";
};
initialScrollOffset?: number;

viewPositionFallback chooses start/end alignment only when the target item exceeds the available viewport. Omit it to preserve viewPosition.

<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.

Scrolling a Search Match into View

New in 3.7: Use itemOffset and itemLength with scrollItemIntoView or scrollIndexIntoView to reveal the match itself, even inside an item taller than the viewport. These are layout distances along the scroll axis, not character indices or string lengths. The region should be within the item. viewportMargin keeps the match clear of overlays.

Measure the match relative to the item's root. In this vertical React Native example, the search layer supplies before, match, and after as separate text blocks. Because the highlighted Text is a direct child of the item's root View, its onLayout gives y and height in the required coordinate space, including the root's padding.

import { Text, View } from "react-native";

type SearchMessage = { id: string; before: string; match: string; after: string };
type MatchRegion = { matchOffset: number; matchLength: number };

export function MessageWithMatch({ message, onMatchLayout }: {
  message: SearchMessage;
  onMatchLayout: (message: SearchMessage, region: MatchRegion) => void;
}) {
  return (
    <View style={{ padding: 12 }}>
      {message.before.length > 0 && <Text>{message.before}</Text>}
      <Text
        style={{ backgroundColor: "yellow" }}
        onLayout={({ nativeEvent: { layout } }) => {
          const matchOffset = layout.y;
          const matchLength = layout.height;
          onMatchLayout(message, { matchOffset, matchLength });
        }}
      >
        {message.match}
      </Text>
      {message.after.length > 0 && <Text>{message.after}</Text>}
    </View>
  );
}

In the component owning listRef, use the current measurement from onMatchLayout when navigating to that match. Pass the message object currently present in data:

async function showMatch(
  message: SearchMessage,
  { matchOffset, matchLength }: MatchRegion,
) {
  await listRef.current?.scrollItemIntoView({
    item: message,
    itemOffset: matchOffset,
    itemLength: matchLength,
    viewportMargin: { start: 24, end: 24 },
  });
}

For index-based navigation, pass the current index to scrollIndexIntoView with the same itemOffset, itemLength, and viewportMargin.

This block layout adds line breaks around the match. For inline highlights or nested content, measure the rendered match relative to the item's root instead; a nested child's onLayout is relative to its immediate parent. Use a host-view measurement such as measureLayout relative to the item root where supported, or accumulate parent offsets. Wrapped inline text requires text-layout geometry for the matching lines; a character offset alone is not a pixel offset. On left-to-right horizontal lists, use x and width instead of y and height; always measure the offset from the item's start along the scroll axis.

Virtualized offscreen items may have no current match measurement. First navigate to the containing item to mount it, then wait for its match layout/measurement callback before requesting the region. Awaiting the first scroll alone does not guarantee that measurement exists. Refresh measurements when the query, item content, width, font size, or layout changes, and discard them when a recycled view represents another item.

With recycleItems, use useRecyclingEffect to remeasure and report the new item's match even if its position and size are unchanged; onLayout may not fire again.

A region fully visible inside the margins needs no scrolling. A region above/before the available viewport aligns to its start margin; one below/after it aligns to its end margin. A region larger than the available space aligns its start, including when it already spans that space. Passing only one region parameter defaults the other to 0; omitting both preserves whole-item behavior, where an item already spanning the available viewport stays in place.

Margins reserve space within the viewport without adding content padding; near content edges, the reachable scroll range can limit alignment. Promise resolution also covers interruption, cancellation, or an unreachable target; it does not prove the match is visible. See the API reference for the full signature.

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;
  onReady?: (info: { anchorIndex?: number; anchorKey?: string; size: number }) => void;
  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. Use anchoredEndSpace.onReady if follow-up scroll work should wait until the anchored tail size is authoritative.
  • Keep a stable keyExtractor; changing keys while adjusting overlay inset will discard size and position caches.

Adaptive Rendering

Use adaptive rendering when rows are expensive and can show a cheaper version during fast scrolls.

experimental_adaptiveRender?: {
  initialMode?: "normal" | "light";
  enterVelocity?: number;
  exitVelocity?: number;
  exitDelay?: number;
  onChange?: (mode: "normal" | "light", reason: "initial" | "ready" | "scroll") => void;
};
import { LegendList, useAdaptiveRender } from "@legendapp/list/react";

function Row({ item }) {
  const mode = useAdaptiveRender();

  return mode === "light" ? (
    <RowSkeleton title={item.title} />
  ) : (
    <FullAnalyticsRow item={item} />
  );
}

<LegendList
  data={items}
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => <Row item={item} />}
  experimental_adaptiveRender={{
    initialMode: "light",
    enterVelocity: 6,
    exitVelocity: 3,
    exitDelay: 250,
  }}
/>;

Pitfalls:

  • Keep the light render similar in height to the normal render, or pair it with fixed sizes so fast scrolling does not introduce large measurement corrections.
  • Do not use this for simple rows; the mode switch is only useful when it avoids meaningful row work such as charts, media, or expensive formatting.
  • Use useAdaptiveRenderChange for imperative pause/resume behavior that should happen when the mode changes.

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: readonly 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
  getFixedItemSize={(info) => {
    switch (info.type) {
      case "header":
        return 36;
      case "item":
        return 48;
      default:
        return undefined;
    }
  }}
  estimatedItemSize={48}
/>

Use SectionList's getFixedItemSize when section headers, footers, separators, or items have exact sizes. Return undefined for row types that should still be measured dynamically.

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

On this page