Elements

List

<List> shows long or unbounded data. Only the rows near the viewport exist: as the user scrolls, rows that leave are recycled for the rows that come in. Which rows exist, their positions, columns, sticky headers and viewability are all computed natively, so scrolling a list of thousands of items costs no more than a list of twenty.

import { mount, Text, View } from "nsh";
import { List } from "nsh/list";

const contacts = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Contact ${i + 1}` }));

function App() {
  return (
    <List each={contacts} estimatedItemSize={56} style={{ flex: 1 }}>
      {(contact, index) => (
        <View style={{ height: 56, paddingHorizontal: 16, justifyContent: "center" }}>
          <Text style={{ fontSize: 16 }}>
            {index() + 1}. {contact().name}
          </Text>
        </View>
      )}
    </List>
  );
}

mount(App);

List comes from nsh/list. It is generic: List<T> checks that each and the row function agree on the item type, which TypeScript infers from each.

For a short list whose rows should all exist (a settings page, a form), an <Each> inside a <Scroll> is simpler. For a list styled as a grouped table, see ListView. The lists guide covers patterns: infinite loading, chat, sections.

#Rows are recycled

The row function runs once per cell, not once per item. When a cell is reused for another item, its item() and index() accessors change and everything that reads them updates:

<List each={messages()}>
  {(message) => <Text>{message().body}</Text>}
</List>

So keep per-item state in your data (keyed by id), not in signals created inside the row function: such a signal would stay with the cell and show up on another item.

#Props

PropTypeDefaultDescription
eachreadonly T[]The items.
children(item: () => T, index: () => number) => JSX.ElementRenders one row.
estimatedItemSizenumber50The size of items not measured yet, in points. Close estimates keep the scroll bar steady.
horizontalbooleanfalseScrolls sideways.
numColumnsnumber1Items per row (vertical lists).
invertedbooleanfalseLaid out from the end, item 0 last (chat style): starts scrolled to the end and keeps its distance from the end as content changes.
stickyHeaderIndicesnumber[]Items that stick to the start while scrolled past (one column, not inverted).
contentContainerStyleListContentStylePadding and gaps of the content, in points: padding, paddingHorizontal, paddingVertical, paddingTop, paddingRight, paddingBottom, paddingLeft, gap, rowGap, columnGap.
ListHeaderComponentListSlotBefore the items: a component (Header) or an element (<Header />).
ListFooterComponentListSlotAfter the items.
ListEmptyComponentListSlotShown instead of the items while each is empty.
ItemSeparatorComponentListSlotBetween items (one column).
initialScrollIndexnumberScrolls to this item before the first frame.
keyExtractor(item: T, index: number) => stringitem.key, item.id or the indexThe key of an item in viewability tokens.
onEndReached() => voidThe end is near: load more.
onEndReachedThresholdnumber1How close to the end (in viewports) onEndReached fires.
onViewableItemsChanged(info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => voidItems became viewable or stopped being viewable.
viewabilityConfigViewabilityConfigWhen items count as viewable (below).
refreshingbooleanThe pull-to-refresh spinner shows.
onRefresh() => voidThe user pulled to refresh.
showsIndicatorbooleantrueThe scroll indicator shows.
scrollValueNativeValueA native value fed with the scroll offset, for scroll-linked animations.
keyboardDismissMode"none" | "on-drag" | "interactive""none"Whether dragging dismisses the keyboard.
styleStyleProp<ViewStyle>The list’s own style (it needs a bounded size: flex: 1 or a height).
refYNode | (node: YNode) => voidThe list’s node (see ref methods).

Lists also take the scrolling props of <Scroll>: pagingEnabled, snapToInterval, snapToOffsets, snapToAlignment, disableIntervalMomentum, decelerationRate, bounces, contentInset, contentOffset, keyboardShouldPersistTaps, scrollEventThrottle and scrollEnabled. Lists do not zoom.

ViewabilityConfig:

FieldTypeDescription
itemVisiblePercentThresholdnumberPercent of an item that must be visible.
viewAreaCoveragePercentThresholdnumberPercent of the viewport an item must cover. Without either threshold, any visible pixel counts.
minimumViewTimenumberMilliseconds items must stay viewable before they are reported.

ViewToken<T> is { item, key, index, isViewable }.

#Events

EventPayloadWhen
onScrollScrollEventThe offset changed.
onScrollBeginDrag / onScrollEndDrag{ x, y }Dragging started / stopped.
onMomentumScrollBegin / onMomentumScrollEnd{ x, y }A glide started / stopped.
onScrollEnd{ x, y }Scrolling came to rest.
onEndReachednoneWithin onEndReachedThreshold viewports of the end.
onViewableItemsChanged{ viewableItems, changed }Viewability changed.
onRefreshnonePulled to refresh.

#Ref methods

MethodDescription
scrollToIndex({ index, animated?, viewPosition?, viewOffset? })Scrolls item index into view. viewPosition places it: 0 at the start of the viewport (default), 0.5 in the middle, 1 at the end; viewOffset shifts it further. Resolved natively with the current (measured or estimated) item sizes.
scrollToOffset({ offset, animated? })Scrolls to an offset along the list’s axis.
scrollToEnd({ animated? })Scrolls to the end of the content (the start of an inverted list).
scrollTo({ x?, y?, animated? })Scrolls to an offset.

#Examples

A grid with a header and viewability:

const visible = signal("");
let list!: YNode;

<List
  ref={list}
  each={photos}
  numColumns={3}
  estimatedItemSize={120}
  contentContainerStyle={{ padding: 12, gap: 8 }}
  viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
  onViewableItemsChanged={({ viewableItems }) => visible.set(`${viewableItems.length} visible`)}
  ListHeaderComponent={<Text onPress={() => list.scrollToIndex({ index: 60, viewPosition: 0.5 })}>Jump to 60</Text>}
  style={{ flex: 1 }}
>
  {(photo) => <Image src={photo().url} style={{ height: 120, borderRadius: 8 }} />}
</List>;

A chat:

<List each={messages()} inverted estimatedItemSize={64} keyboardDismissMode="interactive" style={{ flex: 1 }}>
  {(m) => <Bubble message={m()} />}
</List>

Item 0 is the newest message, drawn at the bottom; new messages prepended to each appear without moving what the user is reading.

#Platform notes

  • Inserting items above the visible rows keeps the visible rows in place on both platforms.
  • Horizontal lists in right-to-left layouts are mirrored natively.
  • Sticky headers are pinned natively on every scroll frame.