Skip to main content
Version: 2.0.0

Paginate or infinite-scroll a large table

Prerequisites

You need a module and a client connected to it. If you don't have them yet, follow the quickstart for your language first.

This guide uses views that take arguments, which were added in SpacetimeDB 2.13. C++ modules can't declare them yet.

In this guide, you'll build a feed of posts that loads 50 posts at a time as the user scrolls. Each client only receives the pages it has loaded, and those pages stay live: new posts show up as soon as they're published. You'll use a view that takes a page number and a page size as arguments.

How it works​

  • A private post table holds the posts, with an index on their id.
  • A create_post reducer adds a post.
  • A feed_page view takes a page number and a page size, and returns the posts of that page, newest first. With a page size of 50, page 0 holds the 50 newest posts, page 1 the 50 before them, and so on.
  • Clients subscribe to feed_page once for each page they load, and drop the subscriptions of pages they no longer need.

Subscriptions keep a set of rows up to date, but they can't sort rows or stop after a given number of them: they have no ORDER BY or LIMIT. A view runs your own code, so it can sort the posts and return a single page.

Define the table​

Add a private post table. Its id is assigned automatically, in the order posts are created, and has an index. The view needs this index: views can't scan a whole table with iter(), so they read rows through indexes instead. It's a regular index rather than a primary key, because a primary key can only look up one value at a time, not a range of them.

import { schema, table, t, Range, SenderError } from 'spacetimedb/server';

const post = table(
  { name: 'post' },
  {
    id: t.u64().autoInc().index('btree'),
    author: t.identity(),
    text: t.string(),
    postedAt: t.timestamp(),
  }
);

const spacetimedb = schema({ post });
export default spacetimedb;

Add a post​

Add a create_post reducer that inserts a post. Pass 0 as the id: SpacetimeDB replaces it with the next value.

export const createPost = spacetimedb.reducer({ text: t.string() }, (ctx, { text }) => {
  if (text === '') {
    throw new SenderError("a post can't be empty");
  }
  ctx.db.post.insert({ id: 0n, author: ctx.sender, text, postedAt: ctx.timestamp });
});

Define the view​

Add a public anonymous view named feed_page, with two arguments: the page number and the page size. It reads every post with a range query on the id index, starting at 0 and with no upper bound. Then it sorts the posts from newest to oldest, skips the posts of the pages before, and returns the next ones. It caps the page size at 100, so a client can't ask for the whole feed at once.

Like a reducer, a view can take arguments. SpacetimeDB computes the view separately for each set of argument values, and because feed_page is anonymous, every client that loads the same page with the same page size shares one result. A view that takes arguments can only be subscribed to as a whole: clients can't add a filter to it.

// The largest page size a client can ask for.
const MAX_PAGE_SIZE = 100;

// One page of the feed, newest posts first.
export const feedPage = spacetimedb.anonymousView(
  { name: 'feed_page', public: true },
  { page: t.u32(), pageSize: t.u32() },
  t.array(post.rowType),
  (ctx, { page, pageSize }) => {
    const size = Math.min(pageSize, MAX_PAGE_SIZE);
    const allPosts = new Range({ tag: 'included', value: 0n }, { tag: 'unbounded' });
    const posts = Array.from(ctx.db.post.id.filter(allPosts)).sort((a, b) => (a.id < b.id ? 1 : -1));
    return posts.slice(page * size, (page + 1) * size);
  }
);

Because feed_page reads every post, SpacetimeDB runs it again each time a post is created or deleted, once for each page that clients have loaded. Each run reads the whole feed, so keep the number of posts each view reads in check: for example, give each channel its own feed, as described in What's next.

Load pages from the client​

On the client, subscribe to page 0 to show the newest posts. Keep the subscription handle of each loaded page:

  • When the user scrolls to the end of the feed, subscribe to the next page. If the loaded pages hold fewer posts than they have room for, the last one isn't full and there are no older posts to load.
  • When a page is far off screen, unsubscribe from it. Its posts leave the client cache.

In TypeScript and C#, subscribe to a view that takes arguments with SQL, for example SELECT * FROM feed_page(3, 50). In Rust, use the typed feed_page(3, 50) query. The posts of every loaded page arrive in the same feed_page table in the client cache, which doesn't keep them in order, so sort them by id before you display them.

Pages count from the newest post, so a new post moves every post one place down: each loaded page gains a post at the top and passes one on to the next page. The loaded pages keep covering the same positions in the feed, for example the 100 newest posts when pages 0 and 1 are loaded.

import { DbConnection, type SubscriptionHandle } from './module_bindings';

// Posts per page.
const PAGE_SIZE = 50;

const conn = DbConnection.builder()
  .withUri('ws://localhost:3000')
  .withDatabaseName('my-app')
  // Start with page 0, the newest posts.
  .onConnect(() => loadPage(0))
  .build();

// The subscription of each loaded page.
const pages = new Map<number, SubscriptionHandle>();

function loadPage(page: number) {
  if (pages.has(page)) {
    return;
  }
  // Views that take arguments are subscribed to with SQL.
  pages.set(page, conn.subscriptionBuilder().subscribe(`SELECT * FROM feed_page(${page}, ${PAGE_SIZE})`));
}

// Call this when the user scrolls to the end of the feed.
function loadOlderPage() {
  const loaded = [...pages.keys()];
  // When the loaded pages aren't full, there are no older posts.
  if (loaded.length === 0 || conn.db.feedPage.count() < BigInt(loaded.length * PAGE_SIZE)) {
    return;
  }
  loadPage(Math.max(...loaded) + 1);
}

// Call this to drop a page that's far off screen.
function unloadPage(page: number) {
  pages.get(page)?.unsubscribe();
  pages.delete(page);
}

// Call this each frame to draw the loaded posts, newest first.
function drawFeed() {
  const posts = Array.from(conn.db.feedPage.iter()).sort((a, b) => (a.id < b.id ? 1 : -1));
  for (const post of posts) {
    console.log(`#${post.id}: ${post.text}`);
  }
}

// Call this when the user publishes a post.
function publish(text: string) {
  conn.reducers.createPost({ text }).catch(console.error);
}

What's next?​

You now have a feed that loads 50 posts at a time, and keeps the loaded pages live:

  • The feed_page view takes a page number and a page size, and returns that page of posts, newest first. Every client that loads the same page shares one result.
  • Clients subscribe to one page at a time, and unsubscribe from the pages they no longer show.

To adapt it to your app:

  • Several feeds. For a feed per channel, add an indexed channel_id column to post and give feed_page a third argument: feed_page(channel_id, page, page_size). Read the posts with filter on channel_id, so each page only reads, and only runs again for, the posts of its own channel.
  • Jump to a page. Subscribe to any page directly, for example to show page 10 without loading pages 0 to 9.
  • Other sort orders. To sort by something else, such as likes, index that column and sort by it in the view.

To learn more about the features used here, see Views, Subscriptions and Indexes.

Next, you can add admin and moderator roles.