Зависимые списки (cascading selects) строятся на идее, при которой выбор значения в одном поле определяет набор доступных значений в другом. Такая модель часто применяется в интерфейсах с иерархическими данными: страна → регион → город, категория → подкатегория → товар, проект → задача → подзадача.
В контексте Tom Select логика строится вокруг динамического обновления options через API инстанса компонента, а также асинхронной подгрузки данных с сервера при изменении родительского значения.
Ключевая особенность подхода заключается в разделении ответственности:
Рассматривается классическая цепочка:
HTML-структура:
<select id="country-select"></select>
<select id="city-select"></select>
Инициализация Tom Select:
const countrySelect = new TomSelect("#country-select", {
valueField: "id",
labelField: "name",
searchField: "name",
preload: true,
load: function(query, callback) {
fetch("/api/countries")
.then(res => res.json())
.then(data => callback(data))
.catch(() => callback());
}
});
const citySelect = new TomSelect("#city-select", {
valueField: "id",
labelField: "name",
searchField: "name",
load: function(query, callback) {
callback(); // пустая загрузка до выбора страны
}
});
На этом этапе второй селект существует, но не имеет контекста для загрузки данных.
Основной механизм зависимости реализуется через событие
change у родительского селекта.
countrySelect.on("change", function(value) {
citySelect.clear();
citySelect.clearOptions();
if (!value) return;
citySelect.load(function(callback) {
fetch(`/api/cities?country_id=${value}`)
.then(res => res.json())
.then(data => callback(data))
.catch(() => callback());
});
});
Поведение:
При загрузке зависимых данных важно предотвращать неконсистентное состояние интерфейса. Применяется временная блокировка селекта.
countrySelect.on("change", function(value) {
citySelect.disable();
citySelect.clear();
citySelect.clearOptions();
if (!value) {
citySelect.enable();
return;
}
citySelect.load(function(callback) {
fetch(`/api/cities?country_id=${value}`)
.then(res => res.json())
.then(data => {
callback(data);
citySelect.enable();
})
.catch(() => {
callback();
citySelect.enable();
});
});
});
Такой подход устраняет ситуацию, при которой пользователь взаимодействует с неполностью загруженными данными.
При повторном выборе одного и того же родительского значения повторные запросы становятся избыточными. Для оптимизации используется кеширование.
const cityCache = new Map();
countrySelect.on("change", function(value) {
citySelect.clear();
citySelect.clearOptions();
if (!value) return;
if (cityCache.has(value)) {
citySelect.addOptions(cityCache.get(value));
return;
}
citySelect.load(function(callback) {
fetch(`/api/cities?country_id=${value}`)
.then(res => res.json())
.then(data => {
cityCache.set(value, data);
callback(data);
})
.catch(() => callback());
});
});
Кеш снижает нагрузку на сервер и ускоряет повторные взаимодействия.
Многоуровневые зависимости строятся аналогично двухуровневым, но добавляется каскад обновлений.
Пример цепочки:
countrySelect.on("change", function(countryId) {
regionSelect.clear();
citySelect.clear();
districtSelect.clear();
if (!countryId) return;
regionSelect.load(cb => {
fetch(`/api/regions?country=${countryId}`)
.then(r => r.json())
.then(cb);
});
});
regionSelect.on("change", function(regionId) {
citySelect.clear();
districtSelect.clear();
if (!regionId) return;
citySelect.load(cb => {
fetch(`/api/cities?region=${regionId}`)
.then(r => r.json())
.then(cb);
});
});
citySelect.on("change", function(cityId) {
districtSelect.clear();
if (!cityId) return;
districtSelect.load(cb => {
fetch(`/api/districts?city=${cityId}`)
.then(r => r.json())
.then(cb);
});
});
Каждый уровень строго зависит от предыдущего, формируя дерево данных.
Часто данные формы уже содержат выбранные значения (например, при редактировании сущности). В этом случае необходимо восстановить цепочку зависимостей.
async function initForm(data) {
await countrySelect.setValue(data.country_id);
await citySelect.load(cb => {
fetch(`/api/cities?country_id=${data.country_id}`)
.then(r => r.json())
.then(cb);
});
citySelect.setValue(data.city_id);
}
Приоритетное правило:
Асинхронные запросы могут завершаться в произвольном порядке, что приводит к некорректному отображению данных. Решение — контроль актуальности запроса.
let currentRequestId = 0;
countrySelect.on("change", function(value) {
const requestId = ++currentRequestId;
citySelect.clear();
citySelect.clearOptions();
if (!value) return;
citySelect.load(function(callback) {
fetch(`/api/cities?country_id=${value}`)
.then(res => res.json())
.then(data => {
if (requestId !== currentRequestId) return;
callback(data);
})
.catch(() => callback());
});
});
Такая проверка предотвращает перезапись актуальных данных устаревшими ответами.
Вместо загрузки полного набора данных и последующей фильтрации на клиенте применяется серверная логика.
Запрос формируется на основе текущего состояния формы:
function loadCities(countryId, query, callback) {
fetch(`/api/cities?country=${countryId}&q=${query}`)
.then(res => res.json())
.then(callback);
}
Интеграция с Tom Select:
citySelect.settings.load = function(query, callback) {
const countryId = countrySelect.getValue();
if (!countryId) {
callback();
return;
}
loadCities(countryId, query, callback);
};
В некоторых случаях зависимость влияет не только на данные, но и на поведение компонента: режим поиска, лимиты, возможность мультивыбора.
countrySelect.on("change", function(value) {
if (value === "small_country") {
citySelect.settings.maxOptions = 50;
citySelect.settings.searchField = ["name"];
} else {
citySelect.settings.maxOptions = 200;
citySelect.settings.searchField = ["name", "alias"];
}
citySelect.refreshOptions(false);
});
Изменение конфигурации выполняется без пересоздания инстанса.
В архитектурах с глобальным состоянием (например, Redux-подобные модели) селекты синхронизируются с хранилищем.
countrySelect.on("change", value => {
store.setState({ country: value });
});
store.subscribe(state => {
if (state.country !== countrySelect.getValue()) {
countrySelect.setValue(state.country);
}
});
Такой подход обеспечивает единый источник истины для формы и UI.
Сервер может возвращать неполные наборы значений. В таких случаях применяется нормализация перед передачей в Tom Select.
function normalize(items) {
return items
.filter(x => x && x.id && x.name)
.map(x => ({
id: String(x.id),
name: x.name
}));
}
Использование нормализации снижает риск некорректного отображения и ошибок выбора.
При частых обновлениях данных важно поддерживать стабильное состояние интерфейса. Используется последовательная очистка и обновление:
Эта последовательность предотвращает появление «висящих» значений и некорректных зависимостей между селектами.