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 a row the latest applied, non-search load returned. Fetched rows alone count: onBbSelectandBbSelectPopover, options kept bystashkeep a pick's text, they do not make it coherent; - 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: a companion you listen to is controlled, so a prune emits once per load and the rows untick when you apply it.
Remove France from Ship to: the cities reload, Paris leaves the model and Milan stays.
Warehouses model: ["milan","paris"]
<template>
<div class="flex max-w-md flex-col gap-4">
<BbSelect
id="coherence-countries"
v-model="shipTo"
item-text="name"
item-value="code"
:items="europe"
label="Ship to"
multiple
name="ship_to"
/>
<!--
No watcher. `dependencies` reloads the cities when the countries
change, and `enforce-coherence` drops only the picks the new list no
longer contains. Milan survives losing France; Paris does not.
-->
<BbSelect
id="coherence-warehouses"
v-model="warehouses"
:dependencies="[shipTo]"
enforce-coherence
item-text="name"
item-value="id"
:items="loadCities"
label="Warehouses"
loading-text="Loading cities…"
multiple
name="warehouses"
empty-text="Pick a country first."
/>
<p class="m-0 text-sm opacity-70">
Warehouses model: <code>{{ JSON.stringify(warehouses) }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { City } from '~/demo-data';
import { cities, countries, delay } from '~/demo-data';
const europe = countries.filter((country) => country.region === 'Europe');
const shipTo = ref<string[]>(['IT', 'FR']);
const warehouses = ref<string[]>(['milan', 'paris']);
// Every component-initiated call must keep the current picks resolvable.
// Returning every city of the chosen countries does that.
const loadCities = (): Promise<City[]> =>
delay(
cities.filter((city) => shipTo.value.includes(city.countryCode)),
500
);
</script>
Pruning waits for an applied, successful load, an empty one included. A still-loading, superseded or failed fetch never prunes, and neither does a search, so a slow or broken request 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. Coherence needs options to check against, so until then:
- 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';
import type { BbSelectItemsContext } 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,
reason,
modelValue, // follows the v-model: a single value here
signal,
}: BbSelectItemsContext<string | null>): Promise<City[]> => {
// Component-initiated: resolve the current selection
// under the new region.
if (reason !== 'search') {
return api.cities.list({
regionId: region.value,
selectedIds: modelValue ? [modelValue] : [],
signal,
});
}
if (!query.trim()) return [];
return api.cities.search({ query, regionId: region.value, signal });
};
</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 reason: 'dependencies'. If the
selected city is not among the fetched rows (a stash does not count), city
resets to null; a still-valid city survives untouched. In multiple mode the
same flow filters the array instead. The provider decides what is valid, so
filter both the page and the ids request by the parent.
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 a paginated provider, 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
tables whose provider pages the rows, or for a business rule that cannot be
expressed as option membership.