Связка «страна → город» относится к классу зависимых селектов, где выбор в первом поле определяет доступный набор значений во втором. В интерфейсах это реализуется через каскадную фильтрацию данных, асинхронную подгрузку и нормализацию справочников. Библиотека Tom Select позволяет строить такие сценарии за счёт комбинации кастомных источников данных, динамических опций и событийного API.
Ключевая особенность подобных интерфейсов — необходимость поддерживать три состояния данных:
При этом важно учитывать задержки сети, частичную загрузку и необходимость очищать зависимые поля при изменении родительского значения.
На уровне модели данных используется иерархическая структура:
const countries = [
{ id: "kz", name: "Казахстан" },
{ id: "ru", name: "Россия" },
{ id: "tr", name: "Турция" }
];
const cities = {
kz: [
{ id: "karaganda", name: "Караганда" },
{ id: "astana", name: "Астана" }
],
ru: [
{ id: "moscow", name: "Москва" },
{ id: "spb", name: "Санкт-Петербург" }
],
tr: [
{ id: "istanbul", name: "Стамбул" },
{ id: "ankara", name: "Анкара" }
]
};
Такой формат позволяет быстро фильтровать данные на клиенте, но в
реальных приложениях чаще используется API, возвращающий города по
country_id.
Первый уровень (страны) обычно инициализируется как статический список:
const countrySelect = new TomSelect("#country", {
valueField: "id",
labelField: "name",
searchField: "name",
options: countries,
placeholder: "Выберите страну"
});
Здесь важно, что список стран редко требует динамической подгрузки. Основная задача — обеспечить стабильный идентификатор, который будет использоваться для запроса городов.
Города изначально пусты, так как зависят от выбранной страны:
const citySelect = new TomSelect("#city", {
valueField: "id",
labelField: "name",
searchField: "name",
options: [],
placeholder: "Сначала выберите страну"
});
На этом этапе поле городов фактически «заблокировано логикой данных», а не UI-состоянием.
Основной механизм синхронизации — обработчик изменения значения страны:
countrySelect.on("change", (countryId) => {
citySelect.clear();
citySelect.clearOptions();
if (!countryId) return;
const newCities = cities[countryId] || [];
citySelect.addOptions(newCities);
citySelect.refreshOptions(false);
});
Здесь реализуется ключевой паттерн:
Это предотвращает ситуацию, когда пользователь выбирает город, не относящийся к текущей стране.
В реальных системах города подгружаются с сервера:
const citySelect = new TomSelect("#city", {
valueField: "id",
labelField: "name",
searchField: "name",
load: function(query, callback) {
const countryId = countrySelect.getValue();
if (!countryId) {
callback();
return;
}
fetch(`/api/cities?country=${countryId}&q=${encodeURIComponent(query)}`)
.then(res => res.json())
.then(data => callback(data))
.catch(() => callback());
}
});
Важный момент: load вызывается при поиске, а значит
фильтрация происходит на сервере. Это снижает нагрузку на клиент и
позволяет работать с большими справочниками.
Корректная синхронизация требует полного сброса состояния зависимого селекта:
function resetCities() {
citySelect.clear(true);
citySelect.clearOptions();
citySelect.setPlaceholder("Выберите страну");
}
И вызов:
countrySelect.on("change", () => {
resetCities();
});
Это предотвращает визуальные артефакты, когда старое значение города остаётся в поле после смены страны.
Для улучшения UX можно заранее загрузить популярные города:
citySelect.addOptions([
{ id: "moscow", name: "Москва" },
{ id: "astana", name: "Астана" }
]);
citySelect.refreshOptions(false);
После выбора страны эти значения либо дополняются, либо полностью заменяются.
Иногда требуется вложенная структура через optgroups:
const groupedCities = [
{
optgroup: "Север",
options: [
{ id: "spb", name: "Санкт-Петербург" }
]
},
{
optgroup: "Центр",
options: [
{ id: "moscow", name: "Москва" }
]
}
];
Инициализация:
const citySelect = new TomSelect("#city", {
optgroupField: "optgroup",
labelField: "name",
valueField: "id",
options: groupedCities.flatMap(g => g.options)
});
Такой подход полезен при работе с крупными странами, где важно визуально разделять регионы.
При частой смене страны имеет смысл кэшировать результаты:
const cache = {};
function loadCities(countryId) {
if (cache[countryId]) {
return Promise.resolve(cache[countryId]);
}
return fetch(`/api/cities?country=${countryId}`)
.then(res => res.json())
.then(data => {
cache[countryId] = data;
return data;
});
}
Интеграция с селектом:
countrySelect.on("change", (countryId) => {
citySelect.clear();
citySelect.clearOptions();
if (!countryId) return;
loadCities(countryId).then(data => {
citySelect.addOptions(data);
citySelect.refreshOptions(false);
});
});
Фильтрация запроса по введённому тексту:
load: function(query, callback) {
const countryId = countrySelect.getValue();
fetch(`/api/cities?country=${countryId}&search=${query}`)
.then(res => res.json())
.then(callback)
.catch(() => callback());
}
Важно учитывать, что поиск всегда должен быть ограничен выбранной страной, иначе пользователь получит неконсистентные результаты.
До выбора страны селект городов можно визуально и функционально отключать:
citySelect.disable();
И включать после выбора:
countrySelect.on("change", (value) => {
if (value) {
citySelect.enable();
} else {
citySelect.disable();
}
});
Это создаёт явный контракт поведения интерфейса.
При сбросе формы важно синхронно очищать оба поля:
function resetForm() {
countrySelect.clear();
citySelect.clear();
citySelect.clearOptions();
citySelect.disable();
}
Это особенно важно при повторном использовании формы без перезагрузки страницы.
Частая проблема — несоответствие форматов ответа сервера и ожиданий селекта. Требуется преобразование:
function normalizeCities(data) {
return data.map(item => ({
id: item.city_id,
name: item.city_name
}));
}
Использование:
fetch("/api/cities?country=kz")
.then(res => res.json())
.then(data => citySelect.addOptions(normalizeCities(data)));
Если страна не выбрана, логика должна быть жёсткой:
citySelect.settings.placeholder = "Сначала выберите страну";
citySelect.refreshState();
В некоторых интерфейсах допускается выбор нескольких городов:
const citySelect = new TomSelect("#city", {
maxItems: 5,
valueField: "id",
labelField: "name",
searchField: "name"
});
При смене страны необходимо очищать все выбранные значения:
countrySelect.on("change", () => {
citySelect.clear(true);
});
При медленной сети возможна гонка запросов. Решение — контроль актуального запроса:
let currentRequest = null;
function loadCities(countryId) {
const requestId = Date.now();
currentRequest = requestId;
return fetch(`/api/cities?country=${countryId}`)
.then(res => res.json())
.then(data => {
if (currentRequest !== requestId) return [];
return data;
});
}
Это предотвращает подмену данных устаревшим ответом.
Поведение системы «страны → города» при использовании Tom Select сводится к нескольким устойчивым правилам:
Такая архитектура обеспечивает предсказуемость интерфейса даже при сложной сетевой логике и больших справочниках.