Skip to content

Coherence

Keep a bound value valid when the options underneath it change, without writing a watcher.

On this page

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 items array, every model entry hash-matches an item's value;
  • with an items function, every model entry hash-matches an item some fetch has returned. On BbSelect and BbSelectPopover, options kept by stash count too;
  • an empty selection is always coherent: [] in multiple mode, null or undefined in 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.

PropWhat it does
dependenciesReactive values that re-run the items provider when they change
deps-debounce-timeDebounce for those reloads. Defaults to 0
enforce-coherenceAfter 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

Componentdependenciesenforce-coherence
BbSelect / BbSelectPopoveryesyes — over a non-empty model it also forces the first load
BbCheckboxGroup / BbRadioGroup / BbSwitchGroupyesyes
BbDropdown pipeline groupsper group, in the group configper group as enforceCoherence, or the dropdown-level prop as the default
BbTableyesyes — 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

  • dependencies or items change: re-run the provider, debounced by deps-debounce-time, then enforce.
  • modelValue changes from outside: on selects and groups, an incoherent new value re-runs the provider defensively, debounced by model-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-coherence over 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.

vue
<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-coherence off and replicating it by hand.
  • Binding many BbSelectPopover instances to one shared ref. Each judges the same value against its own options, and every instance that disagrees emits null. The user's pick is overwritten by its neighbours. Give every row its own model.
  • Hand-rolling BbTable selection 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.