Paginated Feed

Manual navigation with client-side caching for instant page switching.

View Live Demo

Unlike infinite scroll, pagination gives users control over navigation and is often better for SEO and specific data retrieval.

This example implements a Cache-First Strategy. We store fetched pages in a state object (itemsByPage). When the user clicks "Previous", data is served instantly from memory without a network request, unless the "Refresh" button is triggered.

pagination.html
<?php

function getFeed($data)
{
    $page = $data->page ?? 1;
    $size = $data->size ?? 50;

    $items = [];
    for ($i = 0; $i < $size; $i++) {
        $id = ($page - 1) * $size + $i + 1;
        $items[] = [
            'id' => $id,
            'title' => "Item $id",
            'snippet' => "This is a snippet for item $id."
        ];
    }
    return ['items' => $items];
}

?>

<div class="mx-6 my-6 font-sans text-gray-900 dark:text-gray-100">
    <h1 class="text-2xl font-semibold mb-4">📄 Paginated Feed</h1>
    
    <!-- Toolbar -->
    <div class="flex flex-wrap items-center gap-2 my-4">
        <button
            class="inline-flex items-center rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-gray-50 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed"
            onclick="prevPage()"
            disabled="{page === 1 || loadingPage === page}">
            ← Prev
        </button>

        <button
            class="inline-flex items-center rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-gray-50 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed"
            onclick="nextPage()"
            disabled="{(knownLastPage && page >= knownLastPage) || loadingPage === page}">
            Next →
        </button>

        <span class="text-gray-600 dark:text-gray-400 text-sm">
            Page {page}{knownLastPage ? ` / ${knownLastPage}` : ''}
        </span>

        <div class="flex-1"></div>

        <label class="text-gray-600 dark:text-gray-400 text-sm">Page size: </label>
        <select
            class="rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
            value="{pageSize}"
            onchange="onChangePageSize(event)">
            <option>10</option>
            <option>25</option>
            <option>50</option>
            <option>100</option>
        </select>

        <button
            class="inline-flex items-center rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-gray-50 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed"
            onclick="refreshPage()"
            disabled="{loadingPage === page}">
            ⟲ Refresh
        </button>
    </div>

    <!-- List Container -->
    <div class="border border-gray-200 dark:border-neutral-700 rounded-lg p-2">
        <template pp-for="item in pagedItems">
            <article key="{item.id}" class="border-b border-gray-100 dark:border-neutral-800 last:border-b-0 py-2">
                <h3 class="font-medium">{item.title}</h3>
                <p class="text-gray-700 dark:text-gray-300">{item.snippet}</p>
            </article>
        </template>

        <p class="text-gray-600 dark:text-gray-400 text-sm">
            {loadingPage === page ? 'Loading…' : (pagedItems.length === 0 ? 'No items' : '')}
        </p>
    </div>

    <!-- Footer Pagination (Mirrors Top) -->
    <div class="flex flex-wrap items-center gap-2 my-4">
        <button
            class="inline-flex items-center rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-gray-50 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed"
            onclick="prevPage()"
            disabled="{page === 1 || loadingPage === page}">
            ← Prev
        </button>

        <button
            class="inline-flex items-center rounded-md border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-gray-50 dark:hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed"
            onclick="nextPage()"
            disabled="{(knownLastPage && page >= knownLastPage) || loadingPage === page}">
            Next →
        </button>

        <span class="text-gray-600 dark:text-gray-400 text-sm">
            Page {page}{knownLastPage ? ` / ${knownLastPage}` : ''}
        </span>
    </div>
</div>

<script>
    const [page, setPage] = pp.state(1);
    const [pageSize, setPageSize] = pp.state(50);
    
    // 1. Cache Layer: Stores pages as { 1: [...], 2: [...] }
    const [itemsByPage, setItemsByPage] = pp.state({});
    const [pagedItems, setPagedItems] = pp.state([]);
    
    // Loading & Meta State
    const [loadingPage, setLoadingPage] = pp.state(0);
    const [knownLastPage, setKnownLastPage] = pp.state(null);

    // 2. Reactivity: Sync current page items from cache to view
    pp.effect(() => {
        const map = itemsByPage || {};
        setPagedItems(map[page] ?? []);
    }, [itemsByPage, page]);

    // 3. Data Fetching with Cache Check
    async function loadPage(p) {
        // Check cache first
        const cached = itemsByPage && itemsByPage[p];
        if (cached && loadingPage !== p) return;

        setLoadingPage(p);
        try {
            // Fetch Data pp.fetchFunction its Prisma PHP Core utility for server calls
            const { response } = await pp.fetchFunction('getFeed', {
                page: p,
                size: pageSize
            });

            // Update Cache
            setItemsByPage(prev => ({
                ...(prev || {}),
                [p]: response.items
            }));

            // Check if this is the last page
            if (Array.isArray(response.items) && response.items.length < Number(pageSize)) {
                setKnownLastPage(p);
            }
        } finally {
            setLoadingPage(0);
        }
    }

    // Navigation Handlers
    function nextPage() {
        if (!knownLastPage || page < knownLastPage) {
            setPage(p => p + 1);
        }
    }

    function prevPage() {
        if (page > 1) setPage(p => p - 1);
    }

    function onChangePageSize(e) {
        const newSize = parseInt(e.target.value, 10) || 50;
        setPageSize(newSize);
        // Invalidate cache on size change
        setItemsByPage({});
        setKnownLastPage(null);
        setPage(1);
    }

    function refreshPage() {
        // Clear specific page from cache to force reload
        setItemsByPage(prev => {
            const copy = { ...(prev || {}) };
            delete copy[page];
            return copy;
        });
        loadPage(page);
    }

    // Initial Load & Page Change Effect
    pp.effect(() => {
        loadPage(page);
    }, [page, pageSize]);
</script>
Infinite Scroll