receta
Collection

Pagination

Master both pagination strategies for different use cases.

When to Use Which?

StrategyUse CaseProsCons
OffsetAdmin panels, small datasetsSimple, page numbersSlow for large data, real-time issues
CursorSocial feeds, infinite scrollFast, real-time safeNo page numbers, complex

Offset-Based Pagination with paginate()

Best for: Admin dashboards, reports, search results with page numbers.

Basic Usage

import { paginate } from '@ontologyio/receta/collection'

const items = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 }))

const page1 = paginate(items, { page: 1, pageSize: 20 })
// {
//   items: [items 1-20],
//   page: 1,
//   pageSize: 20,
//   total: 100,
//   hasNext: true,
//   hasPrevious: false
// }

Real-World: Admin User List

interface User {
  id: string
  email: string
  role: string
  createdAt: string
}

app.get('/admin/users', async (req, res) => {
  const allUsers = await db.users.findAll()
  
  const result = paginate(allUsers, {
    page: Number(req.query.page) || 1,
    pageSize: 50
  })

  res.json({
    users: result.items,
    pagination: {
      page: result.page,
      pageSize: result.pageSize,
      totalPages: Math.ceil(result.total / result.pageSize),
      totalItems: result.total,
      hasNext: result.hasNext,
      hasPrevious: result.hasPrevious
    }
  })
})

Cursor-Based Pagination with paginateCursor()

Best for: Social feeds, messaging apps, real-time data, infinite scroll.

Basic Usage

import { paginateCursor } from '@ontologyio/receta/collection'

const messages = Array.from({ length: 50 }, (_, i) => ({ id: `msg_${i + 1}` }))

// First page
const page1 = paginateCursor(messages, m => m.id, { limit: 20 })
// { items: [first 20], nextCursor: 'msg_20', hasMore: true }

// Next page. Omit the cursor key, or pass undefined, to start at the beginning.
const page2 = paginateCursor(messages, m => m.id, {
  cursor: page1.nextCursor,
  limit: 20
})
// { items: [messages 21-40], nextCursor: 'msg_40', hasMore: true }

On the last page nextCursor is undefined and hasMore is false. A cursor that isn't in the list starts again from the beginning.

Real-World: GitHub-Style API

interface Event {
  id: string
  type: string
  createdAt: string
  payload: any
}

app.get('/api/events', async (req, res) => {
  const events = await db.events.findAll()

  const result = paginateCursor(
    events,
    e => e.id,
    {
      cursor: req.query.cursor as string,
      limit: Number(req.query.per_page) || 30
    }
  )

  res.json({
    data: result.items,
    pagination: {
      next_cursor: result.nextCursor,
      has_more: result.hasMore
    }
  })
})

Real-World: Infinite Scroll

import { useState } from 'react'

// React component with infinite scroll
function MessageList() {
  const [messages, setMessages] = useState<Message[]>([])
  const [cursor, setCursor] = useState<string | undefined>()
  const [hasMore, setHasMore] = useState(true)

  const loadMore = async () => {
    const response = await fetch(`/api/messages?cursor=${cursor}&limit=20`)
    const data = await response.json()

    setMessages(prev => [...prev, ...data.items])
    setCursor(data.nextCursor)
    setHasMore(data.hasMore)
  }

  return (
    <InfiniteScroll loadMore={loadMore} hasMore={hasMore}>
      {messages.map(msg => <MessageItem key={msg.id} message={msg} />)}
    </InfiniteScroll>
  )
}

Comparing Both Approaches

Performance

// Offset: Gets slower as page number increases
// Page 1: SELECT * FROM items LIMIT 20 OFFSET 0      -- Fast
// Page 100: SELECT * FROM items LIMIT 20 OFFSET 2000 -- Slow!

// Cursor: Consistent speed
// SELECT * FROM items WHERE id > 'cursor' LIMIT 20   -- Always fast

Real-Time Safety

// Offset: Items can shift between pages
const offsetPage1 = paginate(items, { page: 1, pageSize: 10 })
// New item added at position 5
const offsetPage2 = paginate(items, { page: 2, pageSize: 10 })
// Item 10 from page 1 appears again in page 2!

// Cursor: Stable pagination
const cursorPage1 = paginateCursor(items, getId, { limit: 10 })
// New item added - doesn't affect next page
const cursorPage2 = paginateCursor(items, getId, {
  cursor: cursorPage1.nextCursor,
  limit: 10
})
// No duplicates!

Common Patterns

Pattern 1: Page Navigation UI

function PaginationControls({ result }: { result: PaginatedResult<any> }) {
  const totalPages = Math.ceil(result.total / result.pageSize)

  return (
    <div>
      <button disabled={!result.hasPrevious}>Previous</button>
      <span>Page {result.page} of {totalPages}</span>
      <button disabled={!result.hasNext}>Next</button>
    </div>
  )
}

Pattern 2: Load More Button

function LoadMoreButton({ hasMore, onLoad }: { hasMore: boolean, onLoad: () => void }) {
  return hasMore ? (
    <button onClick={onLoad}>Load More</button>
  ) : (
    <span>No more items</span>
  )
}

Pattern 3: Combining with Filters

const filteredAndPaginated = pipe(
  items,
  R.filter(item => item.active),
  R.sortBy(item => item.createdAt),
  paginate({ page: 1, pageSize: 20 })
)

Next Steps

  • Indexing - Safe indexing with collision handling
  • Patterns - More production recipes

On this page