list
Creates a reactive, paginated list with filtering, sorting, and searching capabilities. Supports a subscription pattern for reactivity.
Implementation
View Source Code
ts
import type { Predicate, Sorter } from '../types';
import { search as defaultSearch } from './search';
// #region Meta
export type Meta = Readonly<{
end: number; // inclusive
isEmpty: boolean;
isFirst: boolean;
isLast: boolean;
limit: number;
page: number; // 1-based
pages: number;
start: number; // 1-based
total: number;
}>;
// #endregion Meta
// #region List
export type List<T, F, S> = {
readonly current: readonly T[];
readonly meta: Meta;
subscribe(listener: () => void): () => void;
goTo(page: number): void;
next(): void;
prev(): void;
reset(): void;
search(query: string, opts?: { immediate?: boolean }): void;
setData?(data: readonly T[]): void; // implemented by local
setFilter(filter: F): void;
setLimit(n: number): void;
setSort(sort?: S): 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,
): void;
};
// #endregion List
// #region LocalConfig
type LocalConfig<T> = Readonly<{
debounceMs?: number;
filterFn?: Predicate<T>;
limit?: number;
searchFn?: (items: readonly T[], query: string, tone: number) => readonly T[];
searchTone?: number;
sortFn?: Sorter<T>;
}>;
// #endregion LocalConfig
export function list<T>(initialData: readonly T[], cfg: LocalConfig<T> = {}): List<T, Predicate<T>, Sorter<T>> {
const listeners = new Set<() => void>();
const DEFAULTS = { debounceMs: 300, limit: 10, searchTone: 0.5 } as const;
let rawData: readonly T[] = [...initialData];
let limit = Math.max(1, cfg.limit ?? DEFAULTS.limit);
let filterFn: Predicate<T> = cfg.filterFn ?? (() => true);
let sortFn: Sorter<T> | undefined = cfg.sortFn;
const searchFn = cfg.searchFn ?? ((items: readonly T[], q: string, t: number) => defaultSearch([...items], q, t));
const searchTone = cfg.searchTone ?? DEFAULTS.searchTone;
let query = '';
let offset = 0;
let view: readonly T[] = [];
const notify = () => {
for (const l of listeners) {
l();
}
};
const recompute = () => {
let arr = rawData;
if (query) arr = searchFn(arr, query, searchTone);
if (filterFn) arr = arr.filter(filterFn);
arr = sortFn ? [...arr].sort(sortFn) : [...arr];
const pages = Math.max(1, Math.ceil(arr.length / limit));
offset = Math.min(offset, pages - 1);
view = arr;
};
const slice = (): readonly T[] => {
if (!view.length) return [];
const start = offset * limit;
return view.slice(start, start + limit);
};
const update = () => {
recompute();
notify();
};
let timer: ReturnType<typeof setTimeout> | undefined;
const debouncedSearch = (q: string, ms: number) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
query = q;
timer = undefined;
void update();
}, ms);
};
// initial compute
recompute();
return {
batch(mutator) {
let nextLimit = limit;
let nextFilter = filterFn;
let nextSort = sortFn;
let nextQuery = query;
let nextData = rawData;
let nextOffset = offset;
const clamp = (i: number, total: number, lim: number) =>
Math.max(0, Math.min(i, Math.max(0, Math.ceil(total / lim) - 1)));
mutator({
goTo: (p) => {
nextOffset = clamp(p - 1, view.length, nextLimit);
},
setData: (d) => {
nextData = [...d];
nextOffset = 0;
},
setFilter: (f) => {
nextFilter = f;
},
setLimit: (n) => {
nextLimit = Math.max(1, n);
},
setQuery: (q) => {
nextQuery = q;
nextOffset = 0;
},
setSort: (s) => {
nextSort = s;
},
});
// apply once
limit = nextLimit;
filterFn = nextFilter;
sortFn = nextSort;
query = nextQuery;
rawData = nextData;
offset = nextOffset;
update();
},
get current() {
return slice();
},
goTo(page) {
const pages = Math.max(1, Math.ceil(view.length / limit));
offset = Math.max(0, Math.min(page - 1, pages - 1));
notify();
},
get meta() {
const total = view.length;
const pages = Math.max(1, Math.ceil(total / limit));
const isEmpty = total === 0;
const page = Math.min(offset + 1, pages);
const start = isEmpty ? 0 : (page - 1) * limit + 1;
const end = isEmpty ? 0 : Math.min(page * limit, total);
return {
end,
isEmpty,
isFirst: page <= 1,
isLast: page >= pages,
limit,
page,
pages,
start,
total,
};
},
next() {
const pages = Math.max(1, Math.ceil(view.length / limit));
if (offset < pages - 1) {
offset++;
notify();
}
},
prev() {
if (offset > 0) {
offset--;
notify();
}
},
reset() {
limit = Math.max(1, cfg.limit ?? DEFAULTS.limit);
filterFn = cfg.filterFn ?? (() => true);
sortFn = cfg.sortFn;
query = '';
offset = 0;
update();
},
search(q, opts) {
query = q;
offset = 0;
if (opts?.immediate) {
update();
} else {
debouncedSearch(q, cfg.debounceMs ?? DEFAULTS.debounceMs);
}
},
setData(data) {
rawData = [...data];
offset = 0;
update();
},
setFilter(f) {
filterFn = f;
offset = 0;
update();
},
setLimit(n) {
limit = Math.max(1, n);
offset = 0;
update();
},
setSort(s) {
sortFn = s;
update();
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}Features
- Reactive: Subscribe to changes with the observer pattern
- Synchronous: All operations execute immediately
- Pagination: Automatic chunking with configurable page size
- Filtering: Apply custom filter predicates
- Built-in Fuzzy Search: Automatic search across all object properties with a customizable search function
- Debounced Search: Search with optional immediate mode (300ms default debounce)
- Sorting: Custom sort functions with dynamic updates
- Batch Updates: Apply multiple changes efficiently in one operation
- Rich Metadata: Comprehensive pagination info (current page, total pages, isEmpty, etc.)
- Isomorphic: Works in both Browser and Node.js
API
Type Definitions
ts
export type Meta = Readonly<{
end: number; // inclusive
isEmpty: boolean;
isFirst: boolean;
isLast: boolean;
limit: number;
page: number; // 1-based
pages: number;
start: number; // 1-based
total: number;
}>;ts
export type List<T, F, S> = {
readonly current: readonly T[];
readonly meta: Meta;
subscribe(listener: () => void): () => void;
goTo(page: number): void;
next(): void;
prev(): void;
reset(): void;
search(query: string, opts?: { immediate?: boolean }): void;
setData?(data: readonly T[]): void; // implemented by local
setFilter(filter: F): void;
setLimit(n: number): void;
setSort(sort?: S): 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,
): void;
};ts
type LocalConfig<T> = Readonly<{
debounceMs?: number;
filterFn?: Predicate<T>;
limit?: number;
searchFn?: (items: readonly T[], query: string, tone: number) => readonly T[];
searchTone?: number;
sortFn?: Sorter<T>;
}>;ts
function list<T>(initialData: readonly T[], config?: LocalConfig<T>): List<T, Predicate<T>, Sorter<T>>;Parameters
initialData: readonly T[]- The initial array of data to paginateconfig?: LocalConfig<T>- Optional configuration object (see type definition above for all available options)
Returns
List<T, Predicate<T>, Sorter<T>> - A reactive paginated list instance (see type definition above for all available methods and properties)
Examples
Basic Pagination
ts
import { list } from '@vielzeug/toolkit';
const data = [1, 2, 3, 4, 5, 6];
const instance = list(data, { limit: 3 });
console.log(instance.current); // [1, 2, 3]
console.log(instance.meta.pages); // 2
instance.next();
console.log(instance.current); // [4, 5, 6]
instance.prev();
console.log(instance.current); // [1, 2, 3]Pagination Metadata
ts
import { list } from '@vielzeug/toolkit';
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const instance = list(data, { limit: 3 });
console.log(instance.meta);
// {
// page: 1,
// pages: 4,
// total: 10,
// start: 1,
// end: 3,
// limit: 3,
// isEmpty: false,
// isFirst: true,
// isLast: false
// }Built-in Fuzzy Search
ts
import { list } from '@vielzeug/toolkit';
const users = [
{ name: 'Alice Johnson', email: 'alice@example.com', role: 'admin' },
{ name: 'Bob Smith', email: 'bob@example.com', role: 'user' },
{ name: 'Charlie Brown', email: 'charlie@test.com', role: 'user' },
];
const instance = list(users);
// Built-in search works across all object properties
instance.search('alice', { immediate: true });
console.log(instance.current);
// [{ name: 'Alice Johnson', email: 'alice@example.com', role: 'admin' }]
// Search by email domain
instance.search('example.com', { immediate: true });
console.log(instance.current.length); // 2 (Alice and Bob)
// Case-insensitive fuzzy search
instance.search('CHARLIE', { immediate: true });
console.log(instance.current[0].name); // 'Charlie Brown'
// Debounced search (default)
instance.search('admin');
// After 300ms delay, results will updateFiltering and Searching
ts
import { list } from '@vielzeug/toolkit';
const users = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
{ name: 'Charlie', age: 35 },
];
const instance = list(users, { limit: 10 });
// Filter by age
instance.setFilter((user) => user.age > 25);
console.log(instance.current);
// [{ name: 'Bob', age: 30 }, { name: 'Charlie', age: 35 }]
// Debounced search (default, 300ms delay)
instance.search('Bob');
// After 300ms delay:
// instance.current will be [{ name: 'Bob', age: 30 }]
// Immediate search (no debounce)
instance.search('Charlie', { immediate: true });
console.log(instance.current);
// [{ name: 'Charlie', age: 35 }]Sorting
ts
import { list } from '@vielzeug/toolkit';
const users = [
{ name: 'Charlie', age: 35 },
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 },
];
const instance = list(users);
// Sort by name
instance.setSort((a, b) => a.name.localeCompare(b.name));
console.log(instance.current[0].name); // 'Alice'
// Sort by age descending
instance.setSort((a, b) => b.age - a.age);
console.log(instance.current[0].name); // 'Charlie'
// Remove sorting
instance.setSort();
console.log(instance.current[0].name); // 'Charlie' (original order)Navigate to Specific Page
ts
import { list } from '@vielzeug/toolkit';
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const instance = list(data, { limit: 3 });
// Navigate to page 2
instance.goTo(2);
console.log(instance.current); // [4, 5, 6]
console.log(instance.meta.page); // 2
// Navigate to page 3
instance.goTo(3);
console.log(instance.current); // [7, 8, 9]
// Out of bounds - clamped to valid range
instance.goTo(10);
console.log(instance.current); // [7, 8, 9] (last page)Dynamic Updates
ts
import { list } from '@vielzeug/toolkit';
const instance = list([1, 2, 3]);
// Update dataset (resets to page 1)
instance.setData?.([4, 5, 6, 7, 8]);
console.log(instance.current); // [4, 5, 6, 7, 8]
// Change page size
instance.setLimit(2);
console.log(instance.current); // [4, 5]
console.log(instance.meta.pages); // 4
// Navigate to specific page
instance.goTo(2);
console.log(instance.current); // [6, 7]Reset to Initial State
ts
import { list } from '@vielzeug/toolkit';
const instance = list([1, 2, 3, 4, 5], {
limit: 2,
filterFn: (x) => x > 2,
});
instance.next();
instance.search('query', { immediate: true });
console.log(instance.meta.page); // 2
// Reset to initial state
instance.reset();
console.log(instance.meta.page); // 1
console.log(instance.current); // [3, 4] (initial filter applied)Batch Updates
ts
import { list } from '@vielzeug/toolkit';
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const instance = list(data, { limit: 3 });
// Apply multiple updates in one go (more efficient)
instance.batch((ctx) => {
ctx.setLimit(2);
ctx.setFilter((x) => x % 2 === 0);
ctx.setSort((a, b) => b - a);
ctx.goTo(2);
});
console.log(instance.current); // [6, 4]
console.log(instance.meta.page); // 2Custom Search Function
ts
import { list } from '@vielzeug/toolkit';
const users = [
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
{ name: 'Charlie', email: 'charlie@example.com' },
];
// Custom search that searches across multiple fields
const customSearch = (items: readonly (typeof users)[number][], query: string) => {
const q = query.toLowerCase();
return items.filter((user) => user.name.toLowerCase().includes(q) || user.email.toLowerCase().includes(q));
};
const instance = list(users, { searchFn: customSearch });
instance.search('bob', { immediate: true });
console.log(instance.current);
// [{ name: 'Bob', email: 'bob@example.com' }]
instance.search('example.com', { immediate: true });
console.log(instance.current);
// All users (all emails contain 'example.com')Custom Debounce Time
ts
import { list } from '@vielzeug/toolkit';
const data = ['apple', 'banana', 'cherry', 'date'];
// Use longer debounce delay (500ms)
const instance = list(data, { debounceMs: 500 });
instance.search('ban');
// User keeps typing...
instance.search('banana');
// Only the last search is executed after 500ms
// instance.current will be ['banana']Reactive Updates with Subscribe
ts
import { list } from '@vielzeug/toolkit';
const data = [1, 2, 3, 4, 5];
const instance = list(data, { limit: 2 });
// Subscribe to changes
const unsubscribe = instance.subscribe(() => {
console.log('Data changed!', instance.current);
console.log('Page:', instance.meta.page);
});
instance.next();
// Logs: "Data changed! [3, 4]"
// Logs: "Page: 2"
instance.setLimit(3);
// Logs: "Data changed! [1, 2, 3]"
// Logs: "Page: 1"
// Unsubscribe when done
unsubscribe();
instance.next();
// No logs (unsubscribed)React Integration
ts
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import type { List } from './list';
import { list as createList } from './list';
// Generic hook
export function useList<T>(initialData: readonly T[], config?: Parameters<typeof createList<T>>[1]) {
const instRef = useRef<List<T>>();
// Create once
if (!instRef.current) {
instRef.current = createList<T>(initialData, config);
}
// Reflect prop changes (optional)
useEffect(() => {
instRef.current!.data = initialData;
}, [initialData]);
useEffect(() => {
if (config?.limit != null) instRef.current!.limit = config.limit;
if (config?.sortFn) instRef.current!.sort(config.sortFn);
if (config?.filterFn) instRef.current!.filter(config.filterFn);
}, [config?.limit, config?.sortFn, config?.filterFn]);
const subscribe = (cb: () => void) => instRef.current!.subscribe(cb);
const snapshot = useSyncExternalStore(subscribe, () => ({
current: instRef.current!.current,
meta: instRef.current!.meta,
// you can add other derived things here if needed
}));
return {
...snapshot,
api: instRef.current!, // full list API (next, prev, search, batch, etc.)
};
}Vue Integration
ts
import { shallowRef, ref, onMounted, onUnmounted, watch } from 'vue';
import type { List } from './list';
import { list as createList } from './list';
export function useList<T>(initialData: readonly T[], config?: Parameters<typeof createList<T>>[1]) {
const inst = shallowRef<List<T>>(createList<T>(initialData, config));
const current = ref<readonly T[]>(inst.value.current);
const meta = ref(inst.value.meta);
let unsubscribe: (() => void) | undefined;
onMounted(() => {
unsubscribe = inst.value.subscribe(() => {
current.value = inst.value.current;
meta.value = inst.value.meta;
});
});
onUnmounted(() => {
unsubscribe?.();
});
// If initialData prop changes
watch(
() => initialData,
(d) => {
inst.value.data = d;
// current/meta will update via subscribe
},
{ deep: false },
);
return {
current,
meta,
api: inst, // expose the full list API
};
}Implementation Notes
- All methods are synchronous: Every mutation method executes immediately
- Reactive by default: Use
subscribe()to listen for changes, returns an unsubscribe function - Built-in fuzzy search: Automatically searches across all object properties when no custom searchFn is provided
- Search is debounced by default: Pass
{ immediate: true }for instant execution (300ms default) - Setting data, limit, or filter resets to page 1 automatically
- goTo() uses 1-based indexing and clamps to valid page range
- next() and prev() are safe: They won't throw errors at boundaries
- reset() restores initial state: Clears search, resets to page 1, and restores initial
filterFn - batch() is efficient: Apply multiple updates with only one recalculation and notification
- Original data is never mutated: Internal copies are created
- Custom searchFn: Allows implementing domain-specific search logic (overrides built-in search)
- searchTone parameter: Controls search sensitivity (0-1 range, default: 0.5)