Paginated Feed
Manual navigation with client-side caching for instant page switching.
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>