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:
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:
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
| Trigger | BbSelect / BbSelectPopover | Groups | BbTable | Dropdown 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.trueloads on mount, and runs inonServerPrefetch, so it is the SSR-friendly one.falseis 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:
itemsis an array. There is no request to defer.enforce-coherenceover a non-empty model. Validating a value is impossible without options. An empty model stays lazy, because empty is always coherent.falsewith writing disabled.disable-writinghides the search field, so a search-first load could never be triggered.falsewith 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
loadingref for option fetching. - Debouncing.
query-debounce-time,deps-debounce-timeandmodel-value-debounce-timecover it. A hand-rolled debounce around the provider fights them. - Dependency comparison.
dependenciesare 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:
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:
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:
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:
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;
};
<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.