Skip to content

Fetching data

When items is a function: the provider signatures, 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.

Provider signatures

Three shapes, one per component family.

Query-aware, on BbSelect and BbSelectPopover:

ts
type QueryItemsProvider<T> = (
    query: string,
    prefill: boolean,
    modelValue: any // your v-model: an array with `multiple`, the raw value otherwise
) => T[] | Promise<T[]>;

Non-query, on BbCheckboxGroup, BbRadioGroup, BbSwitchGroup and BbTable:

ts
type ItemsProvider<T> = (
    prefill: boolean,
    modelValue?: any // groups: the v-model; BbTable: the current selection
) => T[] | Promise<T[]>;

BbDropdown pipeline groups take the same non-query shape, where modelValue is that group's own selection.

The prop declares modelValue as any, so annotate it any or your own model type and branch on the shape you know you passed. Anything else the request needs (filters, the parent record) you read from your own refs.

When the provider runs

TriggerBbSelect / BbSelectPopoverGroupsBbTableDropdown group
Initial load(query, true, modelValue)(true, modelValue) on mount(true, selected) on mount(true, modelValue) on first open
User types(query, false, modelValue), debounced query-debounce-time (500ms)
dependencies change(query, true, modelValue), debounced deps-debounce-time(true, modelValue), debounced(true, selected), debounced(true, modelValue), debounced
External modelValue change(query, true, modelValue) — only when incoherent, debounced model-value-debounce-time(true, modelValue) — only when incoherent

What prefill means

There are two prefills on this page and they are not the same thing. The argument tells you who asked; the prop tells you when the first load happens.

The argument. prefill === false means exactly one thing, everywhere: the user typed. Every fetch the component starts itself passes true, and expects the current selection to stay resolvable in the response. That covers the mount or first-open load, a dependency change and a coherence refetch. Components with no search field always pass true. The argument is in their signature so one provider can be shared with a BbSelect and branch identically.

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

  • 'interaction' (default) waits for the first user interaction.
  • true loads on mount, and runs in onServerPrefetch, so it is the SSR-friendly one.
  • 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.

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: errors. A rejected provider is logged with console.error and the previous items are kept. If the user has to know a fetch failed, surface it yourself. useToast is the usual answer.

Canonical patterns

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

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

A non-query component is the same shape minus query. prefill is always true here; it exists so the provider can be shared with a select:

ts
const fetchRoles = (_prefill: boolean, selected?: string[]) =>
    api.roles.list({ selectedIds: selected });

BbTable ships enforce-coherence too, so reach for the prop before writing reconciliation by hand. Where the prop cannot see the case, reconcile unconditionally:

ts
const fetchMembers = async (_prefill: boolean, selectedIds?: string[]) => {
    const rows = await api.members.list({ role: role.value });
    if (selectedIds?.length) {
        selected.value = selectedIds.filter((id) => rows.some((r) => r.id === 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 (): 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,
    });
    totalItems.value = res.total;
    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. 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 no-data-text, or the #loading and #no-data 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 when prefill is true, 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.