# Guides

Guides — Legend List documentation.

Source: https://legend.so/list/v3/guides/

Legend List v3 documentation. Current documentation version.

Practical recipes for common Legend List use cases.

## [Chat Interfaces](https://legend.so/list/v3/guides/#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.

```tsx
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](https://legend.so/list/v3/guides/#floating-composer--overlay-insets) and [useKeyboardScrollToEnd](https://legend.so/list/v3/react-native/keyboard-and-animated#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](https://legend.so/list/v3/guides/#initial-positioning)

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

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

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

```tsx
<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](https://legend.so/list/v3/guides/#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.

```tsx
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`:

```tsx
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](https://legend.so/list/v3/api#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](https://legend.so/list/v3/api#scrollitemintoview) for the full signature.

## [Floating Composer / Overlay Insets](https://legend.so/list/v3/guides/#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.

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

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

```tsx
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](https://legend.so/list/v3/guides/#adaptive-rendering)

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

```ts
experimental_adaptiveRender?: {
  initialMode?: "normal" | "light";
  enterVelocity?: number;
  exitVelocity?: number;
  exitDelay?: number;
  onChange?: (mode: "normal" | "light", reason: "initial" | "ready" | "scroll") => void;
};
```

```tsx
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](https://legend.so/list/v3/guides/#web-layout-and-window-scroll)

Use the React entrypoint for DOM-native lists:

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

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

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

```tsx
<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](https://legend.so/list/v3/guides/#infinite-scrolling)

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

```ts
onStartReached?: ((info: { distanceFromStart: number }) => void) | null | undefined;
onStartReachedThreshold?: number;
onEndReached?: ((info: { distanceFromEnd: number }) => void) | null | undefined;
onEndReachedThreshold?: number;
```

```tsx
<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](https://legend.so/list/v3/guides/#snap-to-indices)

Use `snapToIndices` when specific rows should become scroll snap points.

```tsx
<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](https://legend.so/list/v3/guides/#always-render)

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

```tsx
<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](https://legend.so/list/v3/guides/#maintain-visible-content-position)

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

```ts
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:

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

## [Item Size Hints](https://legend.so/list/v3/guides/#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.

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

```tsx
<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()`.

```tsx
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](https://legend.so/list/v3/guides/#sectionlist-patterns)

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

```tsx
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](https://legend.so/list/v3/api#sectionlist).

[View Markdown](https://legend.so/list/v3/guides.md)[Report a documentation correction](https://github.com/LegendApp/legend-docs/issues/new?title=Docs%3A+Guides&body=Page%3A+https%3A%2F%2Flegend.so%2Flist%2Fv3%2Fguides%0A%0AWhat+is+incorrect+or+missing%3F%0A%0AExpected+behavior+or+supporting+source%3A%0A)
