Skip to content
VersionSize

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() and refresh()
  • 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 server
    • limit?: number - Items per page (default: 10, minimum: 1)
    • debounceMs?: number - Debounce delay for search in milliseconds (default: 300)
    • initialFilter?: F - Initial filter state
    • initialSort?: 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 parameters

Cache 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

Featurelist (Local)remoteList (Server-Side)
Data SourceIn-memory arrayServer API
OperationsSynchronousAsynchronous (promises)
Loading StateN/ABuilt-in meta.loading
Error StateN/ABuilt-in meta.error
CachingN/AAutomatic request caching
Initial DataRequiredFetched on first subscribe
setData()AvailableNot available (use refresh)
refresh()N/ARefetches current page
invalidate()N/AClears cache
Search ImplementationBuilt-in fuzzy searchServer-defined
Filter/Sort LogicClient-sideServer-side

See Also

  • list: Client-side pagination utility
  • search: Fuzzy search functionality
  • filter: Array filtering utility
  • sort: Functional sorting utility