Guides
Paging and lazy lists
A feed from a server grows by the page. A big collection in the core is read a window at a time. Neither ships a whole list across the boundary.
Two kinds of long list#
"Paging" hides two different problems, and they get different tools.
| A feed from a server | A big collection in the core | |
|---|---|---|
| Where the rows are | on a server; the core fetches a page at a time | already in the core (a database, a derived view over 50,000 rows) |
| What the screen shows | every row loaded so far | a window of it |
| Use | an infinite query: a keyed list that grows by the page | Lazy<T>: the platform pages the core's list on demand |
Infinite queries#
An infinite query is a query with a cursor. The function takes the cursor and returns one page; the handle on each platform shows all the pages loaded so far as one list, keyed by the field you name.
Show the Rust code
#[undra::query(key = "feed/{filter}", infinite, item_key = "id", stale = "1m")]
async fn feed(
ctx: &Ctx,
filter: Filter,
#[undra(cursor)] cursor: Option<String>,
) -> Result<undra::query::Page<Post, String>, ApiError> {
// `None` is the first page; `Page { items, next }` says where the next one starts.
let body = fetch(ctx, filter, cursor).await?;
Ok(Page { items: body.posts, next: body.next })
}The cursor is not part of the cache key and not a parameter on the platform. The handle has the five signals of every query and two more, hasNextPage and fetchingNextPage, and data is the list itself, empty before the first page rather than optional.
Show the code for Swift, Kotlin and TypeScript
let feed = try FeedQueryHandle(filter: .all)
List(feed.data) { post in // Post is Identifiable when its key is `id`
PostRow(post)
.onAppear { feed.loadMore(ifNeededFor: post) } // fetches when within 5 rows of the end
}
if feed.fetchingNextPage { ProgressView() }val feed = FeedQueryHandle.create(Filter.All)
val rows by feed.data.collectAsState()
val state = rememberLazyListState()
LazyColumn(state = state) { items(rows, key = { it.id }) { PostRow(it) } }
state.LoadMoreWhenNearEnd(feed) // undra-composeconst feed = await FeedQueryHandle.create(Filter.All);
const posts = useSignal(feed.data); // keyed: a page arrives as a patch of pushes
const sentinel = useLoadMore(feed); // ref callback for the last element
return <>{posts.map((p) => <PostRow key={p.id} post={p} />)}<div ref={sentinel} /></>;Loading the next page appends with the same recorded push a store uses, so the change-set is the new rows and nothing else, however long the list already is. A refetch (a stale entry, invalidate, the app coming to the front, the network returning, a poll) fetches the loaded pages again in order from the first, each with the cursor the previous response returned, and sends only what changed. refetch_pages = N bounds that: a feed scrolled through fifty pages need not cost fifty requests on every refresh.
A mutation edits the whole list with CacheView::update_items, inside the optimistic transaction. persist keeps the first persist_pages pages (one by default) in the same format as any persisted query, so a cold start shows the first page at once. Not in this version: previous pages (paging backwards) and an upper bound on pages kept in memory; for a list that outgrows memory, feed a store's Lazy<T> from the query.
Lazy lists#
A store field of type Lazy<T> is a list the core owns and the platform reads a window of. The platform is told how many rows there are and which version of the list that is; it asks the core for the pages it needs.
Show the code for Rust, Swift, Kotlin and TypeScript
#[undra::store]
pub struct Library {
#[undra(key = "id")]
books: Lazy<Book>, // 100,000 rows; the platform never receives them whole
}
#[undra::api(store)]
impl Library {
pub fn add(&self, title: String) { self.books.push(Book { .. }); } // the recorded API of Signal<Vec<T>>
}let library = try Library()
ScrollView {
LazyVStack { // a lazy container: a List would build every row up front
ForEach(0..<library.books.count, id: \.self) { i in
if let book = library.books[i] { BookRow(book) } else { BookRow.placeholder } // reading requests the page
}
}
}val library = Library.create()
LazyColumn { items(library.books) { index, book -> if (book != null) BookRow(book) else Placeholder() } } // undra-composeconst library = await Library.create();
const { length, getItem } = useLazyList(library.books);
// for TanStack Virtual or react-window: useVirtualizer({ count: length, ... }) and getItem(index)Use a lazy container to show it (LazyVStack, LazyColumn, a virtualised list), never one that builds every row up front: SwiftUI's List over 0..<count reads all of them at once and the cache cannot hold that. Reading a row that is not loaded gives nil, null or undefined and asks for its page of 50 rows and one page of prefetch on each side, once. Requests made in one frame go out together. At most 24 pages are kept, least recently touched first out. A change to the list, whatever it is, is one 12-byte entry (the new length and the new version): the platform keeps showing the rows it has and asks again for the pages on screen, so a change costs the window, never the list. A reply carries the version it was read at, so a page that arrives after a newer change is thrown away and asked for again.
| Cost | |
|---|---|
| Observing a list of 100,000 rows | 20 bytes |
| A change of any size | 12 bytes, then the window |
| A page of 50 rows in the core | microseconds (a few hundred nanoseconds on the reference machine) |
| Rows kept on the platform | at most 24 pages |
Lazy::over(&derived) is a read-only lazy view of a derived list (core concepts): its pages come from the derived index in O(log n + page), so a filtered and sorted view over 50,000 rows pages as cheaply as the list itself. A store with a view needs a restore hook, because the view is rebuilt from its source rather than saved. A Lazy is store state: a snapshot carries its rows, and after a restore the platform gets a new page server and reads its window again.
Rows not yet loaded have no identity, so animations across a re-page follow the index; the loaded rows have their keys. A PagingSource adapter over UndraLazyList for Jetpack Paging can follow if teams ask. On iOS 15 and 16 the Swift class is UndraLazyListObject, an ObservableObject you hold with @ObservedObject.
The playground's Library and feed use both, and the contract scenario S32 checks them on Swift, Kotlin and TypeScript. The pagination recipe shows the older shape, one query per page number.