Region constrains city. The user picks a region, the city options reload, and the city they had chosen is no longer in the list. Something has to notice.
That something is not a watcher you write. It is two props, and this page is about what they guarantee and where they stop.
Read Options and items for value identity: coherence matches by the same hash. Read Fetching data for the provider signatures these flows call.
What coherence means
A component is coherent when every entry in modelValue resolves to an
option the user could see and pick:
- with an
itemsarray, every model entry hash-matches an item's value; - with an
itemsfunction, every model entry hash-matches an item some fetch has returned. OnBbSelectandBbSelectPopover, options kept bystashcount too; - an empty selection is always coherent:
[]in multiple mode,nullorundefinedin single mode.
That last line is not a footnote. Half the behaviour on this page follows from it.
The three props
Declare it; do not hand-roll it.
| Prop | What it does |
|---|---|
dependencies | Reactive values that re-run the items provider when they change |
deps-debounce-time | Debounce for those reloads. Defaults to 0 |
enforce-coherence | After a reload, strip model entries that no longer resolve |
dependencies is compared by hashed value, not by reference. So
:dependencies="[filters]" reloads when the content changes, not every time the
array is rebuilt.
Where it exists
| Component | dependencies | enforce-coherence |
|---|---|---|
BbSelect / BbSelectPopover | yes | yes — over a non-empty model it also forces the first load |
BbCheckboxGroup / BbRadioGroup / BbSwitchGroup | yes | yes |
BbDropdown pipeline groups | per group, in the group config | per group as enforceCoherence, or the dropdown-level prop as the default |
BbTable | yes | yes — prunes every row-keyed model after each load |
What enforcement emits
When enforcement runs and finds the model incoherent, the component emits
update:modelValue with the coherent remainder:
- multiple: the filtered array. Coherent entries survive.
- single:
null. An empty selection is the only coherent fallback. - dropdown groups: selection keys that no longer map to a loaded option are
dropped. They surface through the companion's
v-model.
Pruning never runs against an empty or still-loading item set, so a slow fetch cannot wipe a valid selection.
Single-mode enforcement resolves to null. Seed single models as
ref<T | null>(null) and treat null as the "nothing valid" state in your
submit logic.
When reconciliation runs
dependenciesoritemschange: re-run the provider, debounced bydeps-debounce-time, then enforce.modelValuechanges from outside: on selects and groups, an incoherent new value re-runs the provider defensively, debounced bymodel-value-debounce-time, then enforces. A coherent update fetches nothing.- First load: the initial load is a load like any other. Enforcement runs on it, so a seeded-but-invalid model is pruned as soon as options exist.
None of it runs before the component has loaded once. That is the part that looks like a bug and is not:
- an untouched
prefill: 'interaction'select ignores dependency changes until the first interaction; - group and table dependency watchers wait for the mount load;
- a dropdown group nobody opened never fetches, and never prunes.
Do not "fix" the quiet period with eager watchers. Any first load ends it, including the user's first search.
Two things guarantee that first load with no prefill prop at all:
- an array
items, because there is no request to defer; enforce-coherenceover a non-empty model, because validating a value without options is impossible.
An empty model stays lazy. Empty is always coherent, so there is nothing to check.
enforce-coherence opts each instance into its own load. One field, one
request; N provider-backed instances, N requests. With many instances (a popover
per table row), resolve the options once at page level and pass the same
array down. Then N instances cost zero requests and every one resolves on mount.
Two tied fields
Region constrains city. Changing the region reloads the city options and clears a city that no longer fits.
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
const region = ref<string | null>(null);
const city = ref<string | null>(null);
const loadRegions = (): Promise<Region[]> => api.regions.list();
const loadCities = async (
query: string,
prefill: boolean,
modelValue: any // follows the v-model: a single value here
): Promise<City[]> => {
// Component-initiated: resolve the current selection under the new region.
if (prefill) {
return api.cities.list({
regionId: region.value,
selectedIds: modelValue ? [modelValue] : [],
});
}
if (!query.trim()) return [];
return api.cities.search({ query, regionId: region.value });
};
</script>
<template>
<BbSelect
v-model="region"
label="Region"
:items="loadRegions"
item-text="name"
item-value="id"
/>
<BbSelect
v-model="city"
label="City"
:items="loadCities"
:dependencies="[region]"
:deps-debounce-time="150"
enforce-coherence
item-text="name"
item-value="id"
/>
</template>
A region change re-runs loadCities with prefill = true. The merged result is
what came back plus what stash kept. If the selected city is not in it, city
resets to null; a still-valid city survives untouched. In multiple mode the
same flow filters the array instead.
When a watcher is right
Only for rules the items and dependencies contract cannot express:
cross-component business constraints, server-side revalidation. Keep such a
watcher additive: an extra rule on top of enforce-coherence, never a
replacement.
What it must not be is a blind reset. city.value = null on every upstream
change discards selections the reload would have preserved. That is the exact
work the prop exists to avoid.
Three more shapes that look reasonable and are not:
- Duplicating built-in cleanup across tied components, or turning
enforce-coherenceoff and replicating it by hand. - Binding many
BbSelectPopoverinstances to one shared ref. Each judges the same value against its own options, and every instance that disagrees emitsnull. The user's pick is overwritten by its neighbours. Give every row its own model. - Hand-rolling
BbTableselection pruning after a refetch. The table ships the prop. Reserve provider-side reconciliation for the paginated case, where the prop has to stay off.
Use the smallest flow that fits: dependencies to refetch, then
enforce-coherence to prune. Move reconciliation into the provider only for
paginated tables or a business rule that cannot be expressed as option
membership.