Guides
•4 min read
How to Build Pagination & Infinite Scroll
Cursor-based pagination with infinite scroll, automatic loading, and filter-aware resets — no duplicate fetches, no lost data.
The Problem
The data fetching guide shows single-entity fetch. Real apps need paginated lists: modeling cursor/hasMore, appending pages without losing previous data, preventing duplicate fetches during rapid scrolling, and resetting to page 1 when filters change. Imperative approaches scatter this across scroll handlers, state hooks, and effect cleanup — leading to race conditions and stale data.
The Solution
import { createModule, createSystem, t } from '@directive-run/core';
import { loggingPlugin } from '@directive-run/core/plugins';
interface ListItem {
id: string;
title: string;
category: string;
}
const filters = createModule('filters', {
schema: {
search: t.string(),
sortBy: t.string<'newest' | 'oldest' | 'title'>(),
category: t.string(),
},
init: (facts) => {
facts.search = '';
facts.sortBy = 'newest';
facts.category = 'all';
},
events: {
setSearch: (facts, { value }: { value: string }) => {
facts.search = value;
},
setSortBy: (facts, { value }: { value: 'newest' | 'oldest' | 'title' }) => {
facts.sortBy = value;
},
setCategory: (facts, { value }: { value: string }) => {
facts.category = value;
},
},
});
const list = createModule('list', {
schema: {
items: t.object<ListItem[]>(),
cursor: t.string(),
hasMore: t.boolean(),
isLoadingMore: t.boolean(),
scrollNearBottom: t.boolean(),
lastFilterHash: t.string(),
},
init: (facts) => {
facts.items = [];
facts.cursor = '';
facts.hasMore = true;
facts.isLoadingMore = false;
facts.scrollNearBottom = false;
facts.lastFilterHash = '';
},
derive: {
totalLoaded: (facts) => facts.items.length,
isEmpty: (facts) => facts.items.length === 0 && !facts.hasMore,
},
constraints: {
loadMore: {
crossModuleDeps: ['filters.search', 'filters.sortBy', 'filters.category'],
when: (facts) => {
return facts.hasMore && !facts.isLoadingMore && facts.scrollNearBottom;
},
require: (facts) => ({
type: 'LOAD_PAGE',
cursor: facts.cursor,
}),
},
filterChanged: {
crossModuleDeps: ['filters.search', 'filters.sortBy', 'filters.category'],
when: (facts) => {
const hash = `${facts.filters.search}|${facts.filters.sortBy}|${facts.filters.category}`;
return hash !== facts.lastFilterHash;
},
require: (facts) => ({
type: 'RESET_AND_LOAD',
search: facts.filters.search,
sortBy: facts.filters.sortBy,
category: facts.filters.category,
}),
},
},
resolvers: {
loadPage: {
requirement: 'LOAD_PAGE',
resolve: async (req, context) => {
context.facts.isLoadingMore = true;
const res = await fetch(`/api/items?cursor=${req.cursor}&limit=20`);
const data = await res.json();
context.facts.items = [...context.facts.items, ...data.items];
context.facts.cursor = data.nextCursor || '';
context.facts.hasMore = data.hasMore;
context.facts.isLoadingMore = false;
},
},
resetAndLoad: {
requirement: 'RESET_AND_LOAD',
resolve: async (req, context) => {
context.facts.items = [];
context.facts.cursor = '';
context.facts.hasMore = true;
context.facts.isLoadingMore = true;
context.facts.lastFilterHash = `${req.search}|${req.sortBy}|${req.category}`;
const res = await fetch('/api/items?cursor=&limit=20');
const data = await res.json();
context.facts.items = data.items;
context.facts.cursor = data.nextCursor || '';
context.facts.hasMore = data.hasMore;
context.facts.isLoadingMore = false;
},
},
},
effects: {
observeScroll: {
run: (facts) => {
const sentinel = document.getElementById('scroll-sentinel');
if (!sentinel) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
facts.scrollNearBottom = entry.isIntersecting;
},
{ rootMargin: '200px' },
);
observer.observe(sentinel);
return () => observer.disconnect();
},
},
},
events: {
setScrollNearBottom: (facts, { value }: { value: boolean }) => {
facts.scrollNearBottom = value;
},
},
});
const system = createSystem({
modules: { filters, list },
plugins: [loggingPlugin()],
});
function InfiniteList({ system }) {
const { facts, derived } = useDirective(system);
const items = facts['list::items'];
return (
<div>
<SearchBar
value={facts['filters::search']}
onChange={(v) => system.events.setSearch({ value: v })}
/>
<ul>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
{facts['list::isLoadingMore'] && <Spinner />}
{facts['list::hasMore'] && <div id="scroll-sentinel" />}
{derived['list::isEmpty'] && <EmptyState />}
</div>
);
}
Step by Step
Two modules —
filtersowns search/sort/category,listowns items and pagination state. Filter changes trigger a full reset via thefilterChangedconstraint.IntersectionObserver effect watches a sentinel element at the bottom of the list. When it enters the viewport,
scrollNearBottombecomes true, triggering theloadMoreconstraint.loadMoreconstraint only fires whenhasMore && !isLoadingMore && scrollNearBottom— three conditions that prevent duplicate fetches during rapid scrolling.Page appending — the resolver spreads existing items with new ones:
[...context.facts.items, ...data.items]. The cursor advances, andhasMoreis updated from the API response.Filter reset —
filterChangeduses a hash of current filter values to detect changes. The resolver clears items, resets the cursor, and fetches page 1 with the new filters.loggingPluginlogs every constraint evaluation and resolver execution, making it easy to debug pagination timing in the console.
Common Variations
Offset-based pagination
Replace cursor with page number:
schema: {
page: t.number(),
totalPages: t.number(),
},
constraints: {
loadMore: {
when: (facts) => facts.page < facts.totalPages && !facts.isLoadingMore,
require: (facts) => ({ type: 'LOAD_PAGE', page: facts.page + 1 }),
},
},
Manual "Load More" button
Remove the IntersectionObserver effect and add a button that dispatches scrollNearBottom = true:
<button onClick={() => system.events.setScrollNearBottom({ value: true })}>
Load More
</button>
Optimistic filter resets
Show a skeleton UI immediately while the reset resolver fetches:
events: {
resetForFilter: (facts) => {
facts.items = [];
facts.isLoadingMore = true;
},
},
Related
- Interactive Example — try it in your browser
- Loading & Error States — status tracking patterns
- Batch Mutations — coalescing multiple updates
- Effects — cleanup and IntersectionObserver pattern
- Choosing Primitives — constraints vs effects

