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:
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
| Component | First load | User 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.
Provider calls, newest first
None yet. Open the select.
<template>
<div class="grid gap-6 md:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
<BbSelect
id="fetching-assignee"
v-model="assignee"
clearable
empty-text="Type a name to search the team."
item-text="fullName"
item-value="id"
:items="searchMembers"
label="Assignee"
loading-text="Searching the team…"
name="assignee"
placeholder="Search the team"
:query-debounce-time="300"
stash
/>
<section class="grid content-start gap-2 text-sm">
<p class="m-0 font-medium">Provider calls, newest first</p>
<ol class="m-0 grid list-none gap-1 p-0 font-mono text-xs">
<li v-for="call in calls" :key="call.n" class="flex gap-2">
<span class="opacity-60">#{{ call.n }}</span>
<span class="min-w-0 flex-1 truncate">
{ query: "{{ call.query }}", reason: '{{ call.reason }}', modelValue: {{ call.modelValue ?? 'null' }} }
</span>
<span class="shrink-0 opacity-70">{{ call.found }} result{{ call.found === 1 ? '' : 's' }}</span>
</li>
</ol>
<p v-if="!calls.length" class="m-0 opacity-70">None yet. Open the select.</p>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { BbSelectItemsContext } from 'bitboss-ui';
import type { User } from '~/demo-data';
import { delay, matches, userById, users } from '~/demo-data';
const assignee = ref<number | null>(null);
type Call = {
n: number;
query: string;
reason: string;
modelValue: number | null;
found: number;
};
const calls = ref<Call[]>([]);
/** Stands in for `api.members.search(...)`, with the branches the guide describes. */
const searchMembers = async ({
query,
reason,
modelValue,
}: BbSelectItemsContext<number | null>): Promise<User[]> => {
let result: User[] = [];
// Component-initiated: keep the current selection resolvable.
if (reason !== 'search' && modelValue != null) result = [userById[modelValue]!];
// The user typed: search. An empty query is the idle state, and returns [].
else if (query.trim()) {
result = users.filter((user) => matches(user.fullName, query));
}
const n = (calls.value[0]?.n ?? 0) + 1;
const call = { n, query, reason, modelValue, found: result.length };
calls.value = [call, ...calls.value].slice(0, 6);
return delay(result, 600);
};
</script>
The prop, on BbSelect and BbSelectPopover, controls when the first load
happens:
'interaction'(default) waits for the first user interaction.trueloads on mount. Underbitboss-ui/nuxtit also runs on the server, and the rows ride the payload to the browser.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.
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:
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, nodocument. - 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:
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: 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.
<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:
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:
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:
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:
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;
};
<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.