Skip to content

useDataTable

Wire up a paginated data table backed by a BaseResponse<Paginated<T>> from @basmilius/http-client. It bakes in pagination, search (debounced), filters and single-column sorting, combining useLoaded, usePagination and useDebouncedRef. The fetcher re-runs whenever the page, page size, search, filters, sorting or one of the supplied dependencies changes.

Importing

ts
import { useDataTable } from '@basmilius/common';

Usage

vue
<script setup lang="ts">
    import { useDataTable, useService } from '@basmilius/common';
    import { OrderService, type Order } from '@/services/OrderService';

    type OrderFilters = {
        status: string | null;
    };

    const orders = useService(OrderService);

    const {
        displayEmpty,
        isLoading,
        items,
        page,
        perPage,
        total,
        search,
        filters,
        sort,
        setPage,
        setPerPage,
        toggleSort
    } = useDataTable<Order, OrderFilters>({
        filters: {status: null},
        sort: {field: 'createdAt', direction: 'desc'},
        fetcher: query => orders.list(query)
    });
</script>

<template>
    <input v-model="search" placeholder="Search…">
    <select v-model="filters.status">
        <option :value="null">All</option>
        <option value="open">Open</option>
        <option value="closed">Closed</option>
    </select>

    <table v-if="!displayEmpty">
        <thead>
            <th @click="toggleSort('reference')">Reference</th>
        </thead>
        <tr v-for="order in items" :key="order.id">
            <td>{{ order.reference }}</td>
        </tr>
    </table>
    <p v-else>No orders found.</p>
</template>

The fetcher receives a single query object with the calculated offset, the active limit, the debounced search, the current filters and the active sort. Map those onto your API however you like — useDataTable stays agnostic about how filters or sorting are serialized. Returning false skips the update — useful when a request was cancelled or aborted upstream. Throwing an UnresolvedDependencyException silently swallows the call, which pairs nicely with unrefAll.

When the fetcher throws, the error is captured in the error ref instead of being re-thrown, so you can react to it in the template without wrapping the fetcher yourself. It is reset to null at the start of every fetch. Control-flow exceptions are excluded: UnresolvedDependencyException is still swallowed, and ForbiddenException, UnauthorizedException and HandledException (thrown by guarded) are re-thrown so your global error handler keeps catching them. Because error is typed as unknown, narrow it with a type guard such as isRequestError before reading its fields:

ts
import { isRequestError } from '@basmilius/http-client';

const {error, items} = useDataTable({fetcher});

watch(error, err => {
    if (isRequestError(err)) {
        console.error(err.errorDescription);
    }
});

search, filters and sort are plain refs you mutate directly. Changing any of them resets the page back to one and re-fetches. The filters ref is deep-watched, so both filters.value.status = 'open' and filters.value = {...} work. For column headers, toggleSort(field) cycles that column through asc → desc → none, while setSort sets it imperatively.

Pass a dependencies array of additional reactive sources to trigger a re-fetch when they change.

ts
const tenant = ref(1);
useDataTable({fetcher, dependencies: [tenant]});

Preloading

Use preload to run asynchronous setup once before the very first fetch. While it is pending the table stays in its loading state and every fetch is held back, so seeding an initial filter through the provided filters ref results in a single load instead of a throwaway fetch followed by a filtered one. The callback may be synchronous or return a promise; both a synchronous throw and a rejected promise are swallowed, after which the first fetch runs regardless.

ts
useDataTable<Order, OrderFilters>({
    fetcher: query => orders.list(query),
    preload: async ({filters}) => {
        const {status} = await orders.defaultFilters();
        filters.value.status = status;
    }
});

Seed filters (deep-watched) rather than search here: mutating search is debounced, so it would still trigger an extra fetch shortly after the initial load.

Options

OptionTypeDescription
fetcherDataTableFetcher<TItem, TFilter>Required. Receives a DataTableQuery and returns the page
filtersTFilterInitial filter state
sortDataTableSort | nullInitial sorting
searchstringInitial search term
searchDebounceMsnumberDebounce for the search ref (default 300)
perPagenumberInitial page size (default 25 from usePagination)
preloadDataTablePreload<TFilter>Runs once before the first fetch; holds back fetching while pending
dependenciesMultiWatchSourcesExtra reactive sources that trigger a re-fetch

Returned bindings

PropertyTypeDescription
displayEmptyRef<boolean>true when the first load returned no items
errorRef<unknown>Last fetch error (e.g. a RequestError), or null; control-flow exceptions are re-thrown
isLoadingComputedRef<boolean>Loading flag from useLoaded
itemsRef<TItem[]>Latest page of items
limitsRef<number[]>Available page-size options (5, 10, 25, 50, 100)
pageRef<number>One-based current page
perPageRef<number>Active page size
totalRef<number>Total item count from the latest response
searchRef<string>Search term; bind to an input, debounced internally
filtersRef<TFilter>Filter state; mutate directly (deep-watched)
sortRef<DataTableSort | null>Active sorting
reload()() => Promise<void>Re-runs the fetcher with the current query
setPage(num)(num: number) => voidImperatively change the page
setPerPage(num)(num: number) => voidImperatively change the page size
setTotal(num)(num: number) => voidOverride the total (rarely needed)
setSort(sort)(sort: DataTableSort | null) => voidImperatively change the sorting
toggleSort(field)(field: string) => voidCycle a column through asc → desc → none

Type signature

ts
type DataTableSortDirection = 'asc' | 'desc';

type DataTableSort = {
    readonly field: string;
    readonly direction: DataTableSortDirection;
};

type DataTableQuery<TFilter> = {
    readonly offset: number;
    readonly limit: number;
    readonly search: string;
    readonly filters: TFilter;
    readonly sort: DataTableSort | null;
};

type DataTableFetcher<TItem, TFilter> = (query: DataTableQuery<TFilter>) => Promise<BaseResponse<Paginated<TItem>> | false>;

type DataTablePreloadContext<TFilter> = {
    readonly filters: Ref<TFilter>;
    readonly search: Ref<string>;
};

type DataTablePreload<TFilter> = (context: DataTablePreloadContext<TFilter>) => void | Promise<void>;

declare function useDataTable<TItem, TFilter = Record<string, unknown>>(
    options: UseDataTableOptions<TItem, TFilter>
): UseDataTable<TItem, TFilter>;

See also