remoteList
Creates a reactive, server-side paginated list with automatic data fetching, caching, filtering, sorting, and searching. Perfect for handling large datasets with backend pagination.
Implementation
View Source Code
ts
// #region RemoteMeta
export type RemoteMeta = Readonly<{
end: number; // inclusive
error: string | null;
isEmpty: boolean;
isFirst: boolean;
isLast: boolean;
limit: number;
loading: boolean;
page: number; // 1-based
pages: number;
start: number; // 1-based
total: number;
}>;
// #endregion RemoteMeta
// #region RemoteList
export type RemoteList<T, F, S> = {
readonly current: readonly T[];
readonly meta: RemoteMeta;
subscribe(listener: () => void): () => void;
goTo(page: number): Promise<void>;
invalidate?(): void;
next(): Promise<void>;
prev(): Promise<void>;
refresh(): Promise<void>;
reset(): Promise<void>;
search(query: string, opts?: { immediate?: boolean }): Promise<void>;
setFilter(filter: F): Promise<void>;
setLimit(n: number): Promise<void>;
setSort(sort?: S): Promise<void>;
// Batch updates across properties in one recompute/refetch
batch(
mutator: (ctx: {
setLimit(n: number): void;
setFilter(f: F): void;
setSort(s?: S): void;
setQuery(q: string): void;
setData?(d: readonly T[]): void; // local-only
goTo(p: number): void; // 1-based
}) => void,
): Promise<void>;
};
// #endregion RemoteList
// #region RemoteConfig
type RemoteQuery<F, S> = Readonly<{
filter?: F;
limit: number;
page: number; // 1-based
search?: string;
sort?: S;
}>;
type RemoteResult<T> = Readonly<{ items: readonly T[]; total: number }>;
type RemoteConfig<T, F, S> = Readonly<{
debounceMs?: number;
fetch: (q: RemoteQuery<F, S>) => Promise<RemoteResult<T>>;
initialFilter?: F;
initialSort?: S;
limit?: number;
}>;
// #endregion RemoteConfig
export function remoteList<T, F = Record<string, unknown>, S = { key?: string; dir?: 'asc' | 'desc' }>(
cfg: RemoteConfig<T, F, S>,
): RemoteList<T, F, S> {
const listeners = new Set<() => void>();
const limitDefault = Math.max(1, cfg.limit ?? 10);
const debounceMs = cfg.debounceMs ?? 300;
let page = 1;
let limit = limitDefault;
let search = '';
let filter = cfg.initialFilter as F | undefined;
let sort = cfg.initialSort as S | undefined;
let items: readonly T[] = [];
let total = 0;
let loading = false;
let error: string | null = null;
const cache = new Map<string, RemoteResult<T>>();
const inflight = new Map<string, Promise<void>>();
const keyOf = (q: RemoteQuery<F, S>) => JSON.stringify(q);
const queryOf = (): RemoteQuery<F, S> => ({
filter,
limit,
page,
search: search || undefined,
sort,
});
const assign = (res: RemoteResult<T>) => {
items = res.items;
total = res.total ?? 0;
const pages = Math.max(1, Math.ceil(total / limit));
page = Math.min(Math.max(1, page), pages);
};
const notify = () => {
for (const l of listeners) {
l();
}
};
const fetchQuery = async (q: RemoteQuery<F, S>) => {
const k = keyOf(q);
if (cache.has(k)) {
assign(cache.get(k)!);
return;
}
if (inflight.has(k)) {
await inflight.get(k);
assign(cache.get(k)!);
return;
}
loading = true;
error = null;
notify();
const p = cfg
.fetch(q)
.then((res) => {
cache.set(k, res);
assign(res);
})
.catch((e) => {
error = e?.message ?? 'Request failed';
items = [];
total = 0;
})
.finally(() => {
inflight.delete(k);
loading = false;
notify();
});
inflight.set(k, p);
await p;
};
const update = async () => {
await fetchQuery(queryOf());
};
// debounced search
let timer: ReturnType<typeof setTimeout> | undefined;
const debounced = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void update();
}, debounceMs);
};
// initial fetch is on the first subscriber or call to refresh; up to you
return {
async batch(mutator) {
// Stage local copies
let nextPage = page;
let nextLimit = limit;
let nextSearch = search;
let nextFilter = filter as F | undefined;
let nextSort = sort as S | undefined;
mutator({
goTo: (p: number) => {
nextPage = Math.max(1, p | 0);
},
setFilter: (f: F) => {
nextFilter = f;
nextPage = 1;
},
setLimit: (n) => {
nextLimit = Math.max(1, n);
nextPage = 1;
},
setQuery: (q: string) => {
nextSearch = q;
nextPage = 1;
},
setSort: (s?: S) => {
nextSort = s;
nextPage = 1;
},
});
// Apply and update at once
page = nextPage;
limit = nextLimit;
search = nextSearch;
filter = nextFilter;
sort = nextSort;
await update();
},
get current() {
return items;
},
async goTo(p) {
page = Math.max(1, p | 0);
await update();
},
invalidate() {
cache.clear();
},
get meta() {
const isEmpty = total === 0;
const pages = Math.max(1, Math.ceil(total / limit));
const safePage = Math.min(page, pages);
const start = isEmpty ? 0 : (safePage - 1) * limit + 1;
const end = isEmpty ? 0 : Math.min(safePage * limit, total);
return {
end,
error,
isEmpty,
isFirst: safePage <= 1,
isLast: safePage >= pages,
limit,
loading,
page: safePage,
pages,
start,
total,
};
},
async next() {
page += 1;
await update();
},
async prev() {
page = Math.max(1, page - 1);
await update();
},
async refresh() {
cache.delete(keyOf(queryOf()));
await update();
},
async reset() {
page = 1;
limit = limitDefault;
search = '';
filter = cfg.initialFilter as F | undefined;
sort = cfg.initialSort as S | undefined;
cache.clear();
await update();
},
async search(q, opts) {
search = q;
page = 1;
if (opts?.immediate) await update();
else debounced();
},
async setFilter(f) {
filter = f;
page = 1;
await update();
},
async setLimit(n) {
limit = Math.max(1, n);
page = 1;
await update();
},
async setSort(s) {
sort = s;
page = 1;
await update();
},
subscribe(listener) {
listeners.add(listener);
// optional: trigger an initial load on the first subscription
if (listeners.size === 1 && items.length === 0 && !loading) {
void update();
}
return () => listeners.delete(listener);
},
};
}Features
- Server-Side Pagination: Fetches only the data needed for the current page
- Automatic Caching: Smart request caching to minimize server calls
- Loading & Error States: Built-in loading and error state management
- Reactive: Subscribe to changes with the observer pattern
- Async Operations: All mutations return promises for proper async handling
- Debounced Search: Search with optional immediate mode (300ms default debounce)
- Request Deduplication: Prevents duplicate in-flight requests
- Filtering & Sorting: Server-side filtering and sorting support
- Batch Updates: Apply multiple changes efficiently in one request
- Cache Invalidation: Manual cache clearing with
invalidate()andrefresh() - Rich Metadata: Comprehensive pagination info including loading/error states
- Isomorphic: Works in both Browser and Node.js
API
Type Definitions
ts
export type RemoteMeta = Readonly<{
end: number; // inclusive
error: string | null;
isEmpty: boolean;
isFirst: boolean;
isLast: boolean;
limit: number;
loading: boolean;
page: number; // 1-based
pages: number;
start: number; // 1-based
total: number;
}>;ts
export type RemoteList<T, F, S> = {
readonly current: readonly T[];
readonly meta: RemoteMeta;
subscribe(listener: () => void): () => void;
goTo(page: number): Promise<void>;
invalidate?(): void;
next(): Promise<void>;
prev(): Promise<void>;
refresh(): Promise<void>;
reset(): Promise<void>;
search(query: string, opts?: { immediate?: boolean }): Promise<void>;
setFilter(filter: F): Promise<void>;
setLimit(n: number): Promise<void>;
setSort(sort?: S): Promise<void>;
// Batch updates across properties in one recompute/refetch
batch(
mutator: (ctx: {
setLimit(n: number): void;
setFilter(f: F): void;
setSort(s?: S): void;
setQuery(q: string): void;
setData?(d: readonly T[]): void; // local-only
goTo(p: number): void; // 1-based
}) => void,
): Promise<void>;
};ts
type RemoteQuery<F, S> = Readonly<{
filter?: F;
limit: number;
page: number; // 1-based
search?: string;
sort?: S;
}>;
type RemoteResult<T> = Readonly<{ items: readonly T[]; total: number }>;
type RemoteConfig<T, F, S> = Readonly<{
debounceMs?: number;
fetch: (q: RemoteQuery<F, S>) => Promise<RemoteResult<T>>;
initialFilter?: F;
initialSort?: S;
limit?: number;
}>;ts
function remoteList<T, F = Record<string, unknown>, S = { key?: string; dir?: SortDir }>(
config: RemoteConfig<T, F, S>,
): RemoteList<T, F, S>;Parameters
config: RemoteConfig<T, F, S>- Configuration object with the following properties:fetch: (query: RemoteQuery<F, S>) => Promise<RemoteResult<T>>- Required. Function to fetch data from serverlimit?: number- Items per page (default: 10, minimum: 1)debounceMs?: number- Debounce delay for search in milliseconds (default: 300)initialFilter?: F- Initial filter stateinitialSort?: S- Initial sort state
Returns
RemoteList<T, F, S> - A reactive server-side paginated list instance
Examples
Basic Server-Side Pagination
ts
import { remoteList } from '@vielzeug/toolkit';
// Define your fetch function
const fetchUsers = async (query) => {
const params = new URLSearchParams({
page: query.page,
limit: query.limit,
...(query.search && { search: query.search }),
});
const response = await fetch(`/api/users?${params}`);
const data = await response.json();
return {
items: data.users,
total: data.total,
};
};
// Create remote list instance
const users = remoteList({
fetch: fetchUsers,
limit: 20,
});
// Subscribe to changes
users.subscribe(() => {
console.log('Data updated:', users.current);
console.log('Loading:', users.meta.loading);
console.log('Error:', users.meta.error);
});
// Navigate pages
await users.next();
await users.goTo(5);
await users.prev();With Filtering and Sorting
ts
import { remoteList } from '@vielzeug/toolkit';
type UserFilter = {
role?: 'admin' | 'user';
status?: 'active' | 'inactive';
};
type UserSort = {
key: 'name' | 'email' | 'createdAt';
dir: 'asc' | 'desc';
};
const fetchUsers = async (query) => {
const params = new URLSearchParams({
page: query.page,
limit: query.limit,
});
if (query.filter?.role) params.append('role', query.filter.role);
if (query.filter?.status) params.append('status', query.filter.status);
if (query.sort?.key) params.append('sortBy', query.sort.key);
if (query.sort?.dir) params.append('sortDir', query.sort.dir);
const response = await fetch(`/api/users?${params}`);
return await response.json();
};
const users = remoteList<User, UserFilter, UserSort>({
fetch: fetchUsers,
limit: 25,
initialFilter: { status: 'active' },
initialSort: { key: 'name', dir: 'asc' },
});
// Apply filters
await users.setFilter({ role: 'admin', status: 'active' });
// Change sorting
await users.setSort({ key: 'createdAt', dir: 'desc' });Search with Debouncing
ts
import { remoteList } from '@vielzeug/toolkit';
const users = remoteList({
fetch: fetchUsers,
debounceMs: 500, // Wait 500ms after typing stops
});
// Debounced search (waits 500ms)
users.search('john');
// Immediate search (no debounce)
await users.search('jane', { immediate: true });Loading and Error States
ts
import { remoteList } from '@vielzeug/toolkit';
const users = remoteList({
fetch: async (query) => {
const response = await fetch(`/api/users?page=${query.page}`);
if (!response.ok) throw new Error('Failed to fetch users');
return await response.json();
},
});
// Subscribe to state changes
users.subscribe(() => {
if (users.meta.loading) {
console.log('Loading...');
} else if (users.meta.error) {
console.error('Error:', users.meta.error);
} else {
console.log('Data:', users.current);
}
});
// Trigger fetch
await users.refresh();Batch Updates
ts
import { remoteList } from '@vielzeug/toolkit';
const products = remoteList({
fetch: fetchProducts,
limit: 20,
});
// Apply multiple changes in one request
await products.batch((ctx) => {
ctx.setLimit(50);
ctx.setFilter({ category: 'electronics' });
ctx.setSort({ key: 'price', dir: 'asc' });
ctx.setQuery('laptop');
ctx.goTo(1);
});
// Only one server request is made with all parametersCache Management
ts
import { remoteList } from '@vielzeug/toolkit';
const items = remoteList({
fetch: fetchItems,
});
// Refresh current page (clears cache for current query)
await items.refresh();
// Clear entire cache
items.invalidate();
await items.goTo(1); // Will fetch fresh data
// Reset to initial state and clear cache
await items.reset();Pagination Metadata
ts
import { remoteList } from '@vielzeug/toolkit';
const users = remoteList({
fetch: fetchUsers,
limit: 10,
});
await users.refresh();
console.log(users.meta);
// {
// page: 1,
// pages: 10,
// total: 95,
// start: 1,
// end: 10,
// limit: 10,
// isEmpty: false,
// isFirst: true,
// isLast: false,
// loading: false,
// error: null
// }React Integration Example
tsx
import { remoteList } from '@vielzeug/toolkit';
import { useEffect, useState } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [meta, setMeta] = useState(null);
useEffect(() => {
const list = remoteList({
fetch: async (query) => {
const res = await fetch(`/api/users?page=${query.page}&limit=${query.limit}`);
return await res.json();
},
limit: 20,
});
const unsubscribe = list.subscribe(() => {
setUsers(list.current);
setMeta(list.meta);
});
return unsubscribe;
}, []);
if (meta?.loading) return <div>Loading...</div>;
if (meta?.error) return <div>Error: {meta.error}</div>;
return (
<div>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<button onClick={() => list.prev()} disabled={meta?.isFirst}>
Previous
</button>
<span>
Page {meta?.page} of {meta?.pages}
</span>
<button onClick={() => list.next()} disabled={meta?.isLast}>
Next
</button>
</div>
);
}Advanced: Custom Query Builder
ts
import { remoteList } from '@vielzeug/toolkit';
type ProductFilter = {
category?: string;
minPrice?: number;
maxPrice?: number;
inStock?: boolean;
};
type ProductSort = {
key: 'name' | 'price' | 'rating' | 'sales';
dir: 'asc' | 'desc';
};
const products = remoteList<Product, ProductFilter, ProductSort>({
fetch: async (query) => {
// Build query string from all parameters
const params = new URLSearchParams({
page: String(query.page),
limit: String(query.limit),
});
if (query.search) params.append('q', query.search);
if (query.filter?.category) params.append('category', query.filter.category);
if (query.filter?.minPrice) params.append('minPrice', String(query.filter.minPrice));
if (query.filter?.maxPrice) params.append('maxPrice', String(query.filter.maxPrice));
if (query.filter?.inStock !== undefined) params.append('inStock', String(query.filter.inStock));
if (query.sort?.key) {
params.append('sortBy', query.sort.key);
params.append('sortDir', query.sort.dir || 'asc');
}
const response = await fetch(`/api/products?${params}`);
const data = await response.json();
return {
items: data.products,
total: data.totalCount,
};
},
limit: 24,
debounceMs: 300,
initialFilter: { inStock: true },
initialSort: { key: 'sales', dir: 'desc' },
});
// Use it
await products.search('laptop');
await products.setFilter({ category: 'electronics', minPrice: 500, inStock: true });
await products.setSort({ key: 'price', dir: 'asc' });Implementation Notes
- All methods are async: Every mutation method returns a
Promise<void>to handle server requests - Reactive by default: Use
subscribe()to listen for changes, returns an unsubscribe function - Automatic initial fetch: Data is fetched on the first subscription if no data exists
- Smart caching: Identical queries are cached to prevent redundant server requests
- Request deduplication: In-flight requests are tracked to prevent duplicate fetches
- Search is debounced by default: Pass
{ immediate: true }for instant search (300ms default) - Setting filters/sorts resets to page 1: Ensures consistent behavior when filtering changes
- goTo() uses 1-based indexing: Pages start at 1, not 0
- next() and prev() are safe: They won't throw errors at boundaries
- reset() clears cache: Returns to initial state and clears all cached data
- batch() is efficient: Apply multiple updates with only one server request
- Meta includes loading and error: Use these to show loading spinners and error messages
- invalidate() clears cache: Use when you need to force fresh data on next fetch
- refresh() refetches current page: Clears cache for current query and fetches fresh data
Differences from list
| Feature | list (Local) | remoteList (Server-Side) |
|---|---|---|
| Data Source | In-memory array | Server API |
| Operations | Synchronous | Asynchronous (promises) |
| Loading State | N/A | Built-in meta.loading |
| Error State | N/A | Built-in meta.error |
| Caching | N/A | Automatic request caching |
| Initial Data | Required | Fetched on first subscribe |
| setData() | Available | Not available (use refresh) |
| refresh() | N/A | Refetches current page |
| invalidate() | N/A | Clears cache |
| Search Implementation | Built-in fuzzy search | Server-defined |
| Filter/Sort Logic | Client-side | Server-side |