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
maintainScrollAtEndThresholdfor 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, orinitialScrollAtEndfor initial placement instead of imperative scroll calls inuseEffect. - Provide
getFixedItemSizewhen target rows have exact fixed sizes. UseestimatedItemSizeonly as a rough initial offset hint when dynamic rows are significantly different from100px. - 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
contentInsetEndAdjustmentover padding the list content when the overlay size changes dynamically. - Use
anchoredEndSpacefor the row you want to land near the start after sending. UseanchoredEndSpace.onReadyif 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
useAdaptiveRenderChangefor 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
contentContainerStylefor item spacing such asgap;gap-*classes oncontentContainerClassNamedo 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 (
loadingstate 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
getFixedItemSizefor exact snap positions when item sizes are fixed. - For dynamic rows,
estimatedItemSizeonly improves the initial snap offset before rows are measured. It is usually worth setting only when rows are far from the default100px. - 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 mountedindices: keep explicit indices mountedkeys: keep specific keys mounted (requireskeyExtractor)
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: truestabilizes scroll during size/layout changesdata: falsedoes 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
getItemTypewhen item families have meaningfully different average sizes. - Do not tune
estimatedItemSizejust 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.