Cookbook

Pagination and infinite lists

An infinite list is a keyed list that grows. The core owns the pages, the cursor and the filter, so a page crosses as one-row inserts however long the list already is.

The recipe#

Show the Rust
core/src/paging.rs
/// The feed screen's store.
#[undra::store(restore = "Self::assemble")]
pub struct Feed {
    ctx: WeakCtx,
    /// Bumped by `refresh`: a page that comes back for an older generation is dropped.
    generation: Arc<AtomicU64>,
    #[undra(key = "id")]
    posts: Signal<Vec<Post>>,
    cursor: Signal<Option<String>>,
    exhausted: Signal<bool>,
    loading: Signal<bool>,
    topic: Signal<String>,
    #[undra(key = "id")]
    visible: DerivedList<Post>,
}

/// Fetches the next page and appends it. Returns how many rows were new. Does nothing (and
/// returns 0) while a page is in flight or after the last page.
pub async fn load_more(&self) -> Result<u32, NetError> {
    if self.loading.get() || self.exhausted.get() {
        return Ok(0);
    }
    let ctx = self.ctx.upgrade().map_err(|_| NetError::Closed)?;
    let mine = self.generation.load(Ordering::SeqCst);
    self.loading.set(true);
    let _loading = Loading {
        flag: self.loading.clone(),
        generation: self.generation.clone(),
        mine,
    };

    let path = match self.cursor.get() {
        Some(cursor) => format!("/feed?cursor={}", percent_encode(&cursor)),
        None => "/feed".to_owned(),
    };
    let url = net::url(&ctx, &path)?;
    let page: PageBody = net::json(&net::ok(net::send(&ctx, HttpRequest::get(url)).await?)?)?;
    if self.generation.load(Ordering::SeqCst) != mine {
        return Ok(0); // a refresh started while this page was on its way
    }

    // The server can repeat a row when the list changes between two pages: keep one.
    let fresh: Vec<Post> = self.posts.with(|have| {
        page.items
            .into_iter()
            .filter(|post| !have.iter().any(|h| h.id == post.id))
            .collect()
    });
    let added = u32::try_from(fresh.len()).unwrap_or(u32::MAX);
    // One transaction: the rows, the cursor and the flags reach the platforms together.
    txn(|| {
        for post in fresh {
            self.posts.push(post);
        }
        self.exhausted.set(page.next.is_none());
        self.cursor.set(page.next);
        self.loading.set(false);
    });
    Ok(added)
}

On the platform#

The view is a plain array on the platform. Asking for the next page is a call the list makes when its last row appears.

Show the Swift, Kotlin and TypeScript
swift
struct FeedView: View {
    let feed: Feed

    var body: some View {
        List(feed.visible, id: \.id) { post in
            Text(post.title)
                .task {
                    // The core ignores a call while a page is in flight or after the last page.
                    if post.id == feed.visible.last?.id { _ = try? await feed.loadMore() }
                }
        }
        .searchable(text: Binding(get: { feed.topic }, set: { feed.setTopic(topic: $0) }))
        .refreshable { _ = try? await feed.refresh() }
    }
}
kotlin
class FeedScreen(private val feed: Feed) {
    val rows: StateFlow<List<Post>> = feed.visible

    // Call it when the last row scrolls into view: the core ignores a call while a page is in
    // flight and after the last page.
    suspend fun onEndReached() { feed.loadMore() }

    fun onSearch(text: String) = feed.setTopic(text)
}
tsx
export function FeedView({ feed }: { feed: Feed }) {
  const posts = useSignal(feed.visible);
  return (
    <>
      <input onChange={(e) => void feed.setTopic(e.target.value)} placeholder="Search" />
      {posts.map((post) => <p key={post.id}>{post.title}</p>)}
      {/* The core ignores a call while a page is in flight and after the last page. */}
      <button onClick={() => void feed.loadMore()}>More</button>
    </>
  );
}

What the tests prove#

Limits#

Source: examples/cookbook/core/src/paging.rs.

Edit this page on GitHub