Skip to content
bitboss-ui3.0.0-beta.64

Fetching data

When items is a function: the context object every provider receives, when each one runs, and what prefill means.

On this page

Pass a function to items instead of an array and the component runs it, tracks loading, and discards stale responses. A static array bypasses the provider entirely.

Read Options and items first: mapping and value identity work the same either way. Coherence picks up where this page ends, with what happens to a bound value when a fetch returns different options.

The provider context

Every provider is called with one object and returns T[] or a promise of it. One type per component names the shape it passes:

ts
import type {
    BbSelectItemsContext, // = BbSelectPopoverItemsContext
    BbTableItemsContext,
    BbCheckboxGroupItemsContext, // = BbRadioGroup / BbSwitchGroup
    BbDropdownGroupItemsContext,
} from 'bitboss-ui';

// BbSelect, BbSelectPopover
type BbSelectItemsContext = {
    query: string; // what the user typed, '' until they type
    reason: 'search' | 'initial' | 'dependencies' | 'model' | 'reopen';
    modelValue: any; // your v-model: an array with `multiple`, else the value or null
    signal: AbortSignal;
};
// BbTable: no search field, so no `query`
type BbTableItemsContext = {
    reason: 'initial' | 'dependencies' | 'pagination';
    modelValue: any; // the selection
    signal: AbortSignal;
};
// BbCheckboxGroup, BbRadioGroup, BbSwitchGroup: no `query`
type BbCheckboxGroupItemsContext = {
    reason: 'initial' | 'dependencies' | 'model';
    modelValue: any;
    signal: AbortSignal;
};
// a BbDropdown pipeline group: `query` only when it is `searchable`
type BbDropdownGroupItemsContext = {
    query?: string;
    reason: 'initial' | 'search' | 'dependencies';
    modelValue: any; // the group's own selection
    signal: AbortSignal;
};

Every context type takes your model type as a parameter, so ({ modelValue }: BbSelectItemsContext<string[]>) types it string[] and the items prop accepts the annotated provider as it is. A provider shared between a select and a dropdown group writes ({ query = '', reason, modelValue, signal }). Pass signal to every request: it is aborted when a newer call starts or the component is disposed, never on a KeepAlive deactivation. A rejection or late answer from an aborted call is dropped silently, so there is nothing to catch. Anything else the request needs (filters, the parent record) you read from your own refs; a zero-argument provider still works.

When the provider runs

ComponentFirst loadUser types
BbSelect, BbSelectPopover'initial', per prefill'search'
Groups'initial' on mount—
BbTable'initial' on mount—
Dropdown group'initial', per its prefill'search' if searchable

A dependencies change runs every one of them again with 'dependencies'. A retry arrives the same way. Three other triggers belong to one family each:

  • A model set from outside that the options cannot resolve runs the selects and the groups with 'model'.
  • A select's panel reopened over a searched list runs it with 'reopen'.
  • A provider-backed table whose page or page size moves runs with 'pagination'.

Non-search calls carry the typed query too, so apply query on every call. Every call after the first is debounced: query-debounce-time for typing (500 ms on a select, 300 ms on a dropdown), deps-debounce-time for dependencies and model-value-debounce-time for an outside model change.

The reason and the prefill prop

reason tells you who asked; the prefill prop tells you when the first load happens.

The reason. 'search' means exactly one thing, everywhere: the user typed. Every other call is the component's own and carries its own name. Each one expects the current selection to stay resolvable in the response. Branch on reason !== 'search', never on one non-search value, so every call the component fires lands on the right side.

Open the select below and its first call, the component's own load, arrives with 'initial'. Type a name and every search arrives with 'search'. An empty query returns the provider's idle [], which empty-text turns into a sentence. Pick someone and search again: every call carries the selection as its modelValue.

Search the team

Provider calls, newest first

None yet. Open the select.

The prop, on BbSelect and BbSelectPopover, controls when the first load happens:

  • 'interaction' (default) waits for the first user interaction.
  • true loads on mount. Under bitboss-ui/nuxt it also runs on the server, and the rows ride the payload to the browser.
  • false is search-first: nothing loads until the user types. Opening the panel fetches nothing and the list shows a hint until the first query. Reach for it when the result set is too large or expensive to browse and a query is the only sensible entry point.

false and 'interaction' differ on exactly that point. Both skip the mount load, but 'interaction' fetches on open and false waits for a query.

On any other SSR host (plain Vue SSR, Inertia SSR) providers run in the browser only, unless you provide an SSR-state adapter with two methods. Your payload has to be readable before the app mounts:

ts
import {
    SSR_STATE_INJECTION_KEY,
    type SsrStateAdapter,
} from 'bitboss-ui';

const adapter: SsrStateAdapter = {
    get: (key) => payload[key], // browser, during setup
    set: (key, value) => {
        payload[key] = value; // server, in onServerPrefetch
    },
};
app.provide(SSR_STATE_INJECTION_KEY, adapter);
  • Rows must be plain and serializable: no class instances, nothing with methods.
  • The provider must run on both sides: no window, no document.
  • Only the first load travels. Searches, dependency changes and table pages load in the browser.
  • A long list you don't need in the first paint bloats the payload: give it prefill="interaction".

A BbDropdown pipeline group takes the same three values as its own prefill field. true loads when the dropdown mounts and 'interaction' (the default) when the group first shows. false loads on the first keystroke in a searchable group; a group that is not searchable falls back to loading when it shows. Dropdown groups never load on the server, whatever the value.

Four cases ignore the prop and load anyway, because deferring would buy nothing or break a promise the component already made:

  • items is an array. There is no request to defer.
  • enforce-coherence over a non-empty model. Validating a value is impossible without options. An empty model stays lazy, because empty is always coherent.
  • false with writing disabled. disable-writing hides the search field, so a search-first load could never be triggered.
  • false with a non-empty model. The selection's text has to resolve in order to render.

BbSelect additionally upgrades any non-empty model to an immediate prefill so the in-field selection can show its text. A standalone BbSelectPopover does not: the trigger is yours, so label it from your own state rather than reaching for prefill: true.

What the component handles

Four things you do not write:

  • Race protection. Out-of-order responses are discarded; only the latest request may write items.
  • Loading. In-flight requests are tracked internally. Do not add your own loading ref for option fetching.
  • Debouncing. query-debounce-time, deps-debounce-time and model-value-debounce-time cover it. A hand-rolled debounce around the provider fights them.
  • Dependency comparison. dependencies are compared by hashed value, not by reference, so :dependencies="[filters]" refetches when the content changes and not merely when the array identity does.

One thing you do write: failure. A provider throws to fail; it never returns [] for one, because an empty array is a real result. On a failed call the component clears the rows, logs the error and shows failed-text in its empty state; the #empty slot receives what you threw as error. There is no built-in retry: bump a counter you list in dependencies.

vue
<BbTable
    :items="fetchInvoices"
    :dependencies="[status, retry]"
    failed-text="The invoices could not be loaded"
>
    <template #empty="{ error }">
        <template v-if="error">
            Couldn't load the invoices.
            <BbButton size="sm" variant="ghost" @click="retry++">Try again</BbButton>
        </template>
        <template v-else>No invoices match these filters.</template>
    </template>
</BbTable>

Canonical patterns

A query component branches explicitly and returns the same item shape from every path:

ts
const searchMembers = async ({
    query,
    reason,
    modelValue,
    signal,
}: BbSelectItemsContext<string[]>): Promise<Member[]> => {
    // Component-initiated: keep the current selection resolvable.
    if (reason !== 'search' && modelValue.length) {
        return api.members.byIds(modelValue, { signal });
    }
    if (!query.trim()) return []; // project-defined idle behaviour
    return api.members.search({ query }, { signal });
};

That branch only keeps the current pick resolvable. To also list the picks beside each page of results, pair the provider with stash. Make the ids request fail soft, so a deleted id cannot lose the page. Under enforce-coherence the ids request is required, create forms included, because enforcement checks the fetched rows alone.

A selected row the page holds keeps its place in the page, so an ordered list stays ordered. Picks the page lacks (the ones stash still names) are listed first, leading their group, and keep that place until the panel closes; when a later load brings one, it moves to its place in the list.

A non-query component has no query in its context:

ts
const fetchRoles = ({ modelValue, signal }: BbCheckboxGroupItemsContext<string[]>) =>
    api.roles.list({ selectedIds: modelValue }, { signal });

BbTable ships enforce-coherence too, so reach for the prop before writing reconciliation by hand. Do not combine it with provider-fed pagination, where every page turn is a load. A paginated table reconciles in the provider instead, where modelValue is the current selection and the new rows are known:

ts
const fetchMembers = async ({
    modelValue: selectedIds,
    signal,
}: BbTableItemsContext<string[] | undefined>) => {
    const rows = await api.members.list({ role: role.value }, { signal });
    if (selectedIds?.length) {
        const ids = new Set(rows.map((row) => row.id));
        selected.value = selectedIds.filter((id) => ids.has(id));
    }
    return rows;
};

On a server-driven table, forward the page state and refetch through dependencies rather than a manual watch:

ts
const fetchInvoices = async ({
    signal,
}: BbTableItemsContext): Promise<Invoice[]> => {
    const res = await api.invoices.list({
        search: search.value,
        status: status.value,
        orderBy: sort.value, // the v-model:sort array, forwarded as-is
        page: page.value,
        signal,
    });
    if (!signal.aborted) totalItems.value = res.total; // a superseded answer must not write it
    return res.data;
};
vue
<BbTable :items="fetchInvoices" :dependencies="[search, status, sort]" />

page is deliberately not in that list: a provider-backed table already refetches when its shared page moves, with reason: 'pagination'. List only your own filters and sort.

The idle state

What a query component returns for an empty query is a product decision, not a default. Make it once and keep it deterministic across the app.

Empty query, nothing selected: return [] for an explicit empty state, or a default list for guided discovery.

Empty query, values selected: return at least the selected items, alone or merged with a default list, so the selection is never orphaned. Enabling stash on the select keeps already-picked options resolvable across searches without re-including them in every response.

Fill the panel's own states with loading-text and empty-text, or the #loading and #empty slots. An empty panel with no words in it reads as a broken request.

Build the provider in three passes: return a deterministic result for an empty query, keep selected values resolvable on every reason !== 'search' call, then add dependencies for external filters. If a dependency can make the model invalid, finish the flow with enforce-coherence instead of a reset watcher.