A createInfiniteResource helper for solid js
npm install @doeixd/create-infinite-resource-solidcreateInfiniteResourceA SolidJS primitive for managing paginated data fetching with built-in memory management and intersection observer support.
uniqueBy for cursor-based pagination``bash`
npm install @doeixd/create-infinite-resource-solid
Managing infinite scroll in SolidJS typically involves coordinating several primitives (resources, signals, effects) while handling pagination state, memory cleanup, and intersection observers. This primitive handles these concerns while remaining flexible enough for cursor-based pagination, complex data structures, and memory constraints.
This primitive wraps createResource with some key differences:
1. The fetcher receives a context object for pagination control: = { ; // Set next page/cursor // Usage
`ts/api?page=${page}
type FetcherContext
setPageKey: Setter
hasReachedEnd: Accessor
setHasReachedEnd: Setter
signal: AbortSignal; // For cancelling stale requests
direction: 'forward' | 'backward'; // Pagination direction
}
const resource = createInfiniteResource(
async (page, { setPageKey, setHasReachedEnd, signal }) => {
const data = await fetch(, { signal });
const json = await data.json();
// Either set next page
if (json.nextPage) {
setPageKey(json.nextPage);
} else {
// Or mark as complete
setHasReachedEnd(true);
}
return json.items;
}
);
`
2. Pages are accumulated rather than replaced:
`ts
// Default behavior: Flattens arrays
const { data } = createInfiniteResource
data(); // ["item1", "item2", "item3"] (from all pages)
// Custom merging: Preserve page structure
const { data } = createInfiniteResource
mergeData: (prev, next) => [...prev, next]
});
data(); // [page1, page2, page3]
`
1. Memory Management
`ts`
createInfiniteResource(fetcher, {
maxPages: 5 // Only keep last 5 pages
});
data()
When maxPages is hit, oldest pages are removed. This affects what's returned from but doesn't refetch dropped pages on scroll up.
2. Loading States
`ts`
const { pageData, data } = createInfiniteResource();
pageData.loading; // Current page loading
data(); // All accumulated data (even during loads)
Unlike regular resources, you get both the current page's loading state and accumulated data.
3. Intersection Observer 1. Cursor-based Pagination createInfiniteResource 2. Error Handling with Retries 3. Virtual Lists #### 1. Deduplication with Perfect for cursor-based pagination where items can appear across page boundaries: const { data } = createInfiniteResource if (json.nextCursor) setPageKey(json.nextCursor); // data() now contains unique posts only, even if they appear in multiple pages #### 2. Bi-directional Pagination Load data in both directions - perfect for chat applications: const response = await fetch(endpoint); if (messages.length > 0) { return messages; // Load newer messages (default forward direction) // Load older messages #### 3. AbortSignal for Race Conditions Prevent stale data when users rapidly trigger pagination: setPageKey(page + 1); // Rapid calls won't cause race conditions #### 4. Refetch Current Page Refresh data without duplicating: // User adds new item, refresh current page // Load next page as usual #### 5. Reactive Directive Conditions The // Observer dynamically connects/disconnects based on condition
`ts
// Basic
// With conditions
() => !isError() && !hasReachedEnd(),
getNextPage
]}>
``
The directive automatically cleans up observers and respects loading states.$3
ts/api?cursor=${cursor}
type Response = { items: Item[], nextCursor: string | null }
async (cursor, { setNextPageKey, setHasReachedEnd }) => {
const data = await fetch();`
if (data.nextCursor) {
setNextPageKey(data.nextCursor);
} else {
setHasReachedEnd(true);
}
return data;
},
{
initialPageKey: 'initial',
mergeData: (prev, next) => [...prev, next] // Keep cursor info
}
);`ts`
createInfiniteResource(fetcher, {
onError: (error) => {
if (error.status === 429) { // Rate limit
setTimeout(getNextPage, 1000);
}
}
});`ts`
// Keep limited window of data in memory
createInfiniteResource(fetcher, {
maxPages: 3,
mergeData: (prev, next) => {
const window = [...prev, next].slice(-3);
virtualizer.setItemCount(totalCount);
return window;
}
});uniqueBy$3
`ts/api/posts?cursor=${cursor}
type Post = { id: string; title: string; timestamp: number };
async (cursor, { setPageKey, signal }) => {
const response = await fetch(, { signal });`
const json = await response.json();
return json.posts;
},
{
initialPageKey: 'initial',
uniqueBy: (post) => post.id // Automatically deduplicates by ID
}
);`ts/api/messages/before/${messageId}
const { getNextPage, getPreviousPage, data } = createInfiniteResource(
async (messageId, { setPageKey, direction }) => {
const endpoint = direction === 'backward'
? /api/messages/after/${messageId}
: ;`
const messages = await response.json();
const nextId = direction === 'backward'
? messages[0].id
: messages[messages.length - 1].id;
setPageKey(nextId);
}
},
{ initialPageKey: 'latest' }
);
getNextPage();
getPreviousPage(); // or getNextPage('backward')`ts/api?page=${page}
const { getNextPage } = createInfiniteResource(
async (page, { setPageKey, signal }) => {
try {
// Pass signal to fetch - it will auto-cancel on new requests
const response = await fetch(, { signal });`
const data = await response.json();
return data;
} catch (err) {
if (err.name === 'AbortError') {
// Request was cancelled - this is expected
return [];
}
throw err;
}
},
{ initialPageKey: 1 }
);
getNextPage();
getNextPage(); // First request auto-cancelled
getNextPage(); // Second request auto-cancelled
// Only the last request completes`ts`
const { refetchCurrentPage, getNextPage } = createInfiniteResource(
fetcher,
{ initialPageKey: 1 }
);
onItemAdded(() => {
refetchCurrentPage(); // Replaces last page data, doesn't append
});
getNextPage(); // Appends as expectedrefetchOnView directive now reacts to condition changes:`tsx
const [isPaused, setIsPaused] = createSignal(false);
getNextPage
]}>
{isPaused() ? 'Paused' : 'Loading...'}
// Pause infinite scroll
setIsPaused(true); // Observer disconnects automatically
// Resume
setIsPaused(false); // Observer reconnects
`
1. maxPages drops old data but doesn't refetch - consider UX implicationssetPageKey
2. Default array flattening assumes uniform page data
3. Page keys must be managed manually through uniqueBy
4. The directive assumes element visibility means "load more"
5. only works with default array flattening, not with custom mergeData
6. AbortSignal cancels the fetch request but doesn't prevent the fetcher function from running
`ts
createInfiniteResource<
T, // Response type (e.g., Product[])
P = number | string // Page key type
>
// For complex data:
createInfiniteResource
// Response = { items: Product[], cursor: string }
// Cursor = string
`
For non-array responses, each page's data is preserved:
`tsx
type ThreadPage = {
messages: Message[];
participants: User[];
cursor: string;
};
const { data } = createInfiniteResource
async (cursor) => {
const response = await fetch(/api/thread?cursor=${cursor});
return response.json();
},
{
initialPageKey: 'initial',
// Each page is preserved as an array element
mergeData: (prevPages, newPage) => [...prevPages, newPage]
}
);
// Access individual pages
data().map(page => ({
messages: page.messages,
participants: page.participants
}));
`
`ts`
function createInfiniteResource
fetcher: (
pageKey: P,
context: FetcherContext
) => Promise
options?: InfiniteResourceOptions
): InfiniteResourceReturn
#### Options
`ts
type InfiniteResourceOptions
// Initial page key passed to fetcher
initialPageKey: P;
// Maximum number of pages to keep in memory
maxPages?: number;
// Custom function to merge pages
mergeData?: (prevPages: T[], newPage: T) => T[];
// Extract unique key from items for deduplication (NEW)
// Only applies when T is an array type
uniqueBy?: T extends readonly (infer Item)[]
? (item: Item) => string | number
: never;
// Called when fetcher throws
onError?: (error: Error) => void;
// All createResource options
initialValue?: T;
name?: string;
deferStream?: boolean;
storage?: () => Signal
} & ResourceOptions
`
#### Fetcher Context = { ; // Check if at end // Mark as complete // AbortSignal for cancelling requests (NEW) // Current pagination direction (NEW)
`ts`
type FetcherContext
// Set the next page key
setPageKey: Setter
hasReachedEnd: Accessor
setHasReachedEnd: Setter
signal: AbortSignal;
direction: 'forward' | 'backward';
}
#### Return Value
`ts
type InfiniteResourceReturn
// Merged data from all pages
// If T is an array type, flattens by default
data: Accessor
// Raw page responses
allData: Accessor
// Current page resource
pageData: Resource
// Trigger next page load (NEW: now accepts direction)
getNextPage: (direction?: 'forward' | 'backward') => void;
// Convenience for backward pagination (NEW)
getPreviousPage: () => void;
// Refetch current page without duplicating (NEW)
refetchCurrentPage: () => void;
// Get/set page key
pageKey: Accessor
;
setPageKey: Setter
;
// End of data tracking
hasReachedEnd: Accessor
setHasReachedEnd: Setter
// Intersection observer directive (NEW: now reactive)
refetchOnView: Directive
// Underlying resource
resource: ResourceReturn
}
// Directive arguments
type RefetchDirectiveArgs = [
boolean | (() => boolean), // Condition
() => void // Callback
] | (() => [
boolean | (() => boolean),
() => void
])
`
#### getNextPage
Triggers the next page load using the current page key.
`ts`
const { getNextPage } = createInfiniteResource(fetcher);
getNextPage(); // Loads next page if !hasReachedEnd
#### refetchOnView
Directive for viewport-based loading.
`ts
// Attach to element
// With reactive condition
$3
#### data
Returns merged data from all pages. By default, flattens arrays:
`ts
// With array responses
type T = Product[]
const { data } = createInfiniteResource();
data(); // Product[] (flattened from all pages)// With custom merging
const { data } = createInfiniteResource({
mergeData: (prev, next) => [...prev, next]
});
data(); // Product[][] (array of pages)
`#### pageData
Resource for current page with loading states:
`ts
const { pageData } = createInfiniteResource();
pageData.loading; // Current page loading
pageData.error; // Current page error
pageData(); // Current page data
`#### hasReachedEnd
Tracks if all data has been loaded:
`ts
const { hasReachedEnd } = createInfiniteResource();
hasReachedEnd(); // boolean// Common pattern
when={!hasReachedEnd()}
fallback="No more items"
>
`$3
The underlying resource is exposed for advanced cases:
`ts
const { resource } = createInfiniteResource(fetcher);
const [data, { refetch }] = resource;// Manual refetch with context
refetch({
setNextPageKey,
hasReachedEnd,
setHasReachedEnd
});
``