Каскадные селекты

Каскадные селекты (dependent selects) в Tom Select представляют собой механизм, при котором значение одного выпадающего списка определяет содержимое другого. Такой подход широко используется в формах с иерархическими данными: страна → регион → город, категория → подкатегория → товар, производитель → модель и так далее. В основе реализации лежит синхронизация нескольких экземпляров Tom Select и динамическое обновление их опций через API.

Типичная схема включает два или более селектов, где каждый последующий зависит от значения предыдущего. Основной принцип — при изменении родительского селекта происходит перезагрузка данных дочернего.

HTML-структура:

<select id="country"></select>
<select id="city"></select>

Инициализация первого уровня:

const countrySelect = new TomSelect('#country', {
  valueField: 'id',
  labelField: 'name',
  searchField: 'name',
  load: function(query, callback) {
    fetch('/api/countries')
      .then(res => res.json())
      .then(data => callback(data))
      .catch(() => callback());
  }
});

Связь через событие изменения

Ключевой механизм каскадирования — обработка события изменения значения. При выборе страны происходит очистка и перезагрузка списка городов.

const citySelect = new TomSelect('#city', {
  valueField: 'id',
  labelField: 'name',
  searchField: 'name',
  load: function(query, callback) {
    callback(); 
  }
});

Подключение зависимости:

countrySelect.on('change', function(value) {
  citySelect.clear();
  citySelect.clearOptions();
  citySelect.load(function(callback) {
    fetch(`/api/cities?country_id=${value}`)
      .then(res => res.json())
      .then(data => callback(data))
      .catch(() => callback());
  });
});

Важный момент заключается в том, что метод clearOptions() полностью удаляет старые данные, предотвращая смешивание значений разных контекстов.

Асинхронная подгрузка данных

Каскадные селекты почти всегда опираются на серверную фильтрацию. Это позволяет уменьшить объем данных на клиенте и повысить производительность.

Серверная логика обычно принимает идентификатор родителя:

GET /api/cities?country_id=10

Tom Select при этом не хранит всю иерархию, а только запрашивает актуальный набор опций при каждом изменении родительского значения.

Обработка состояния загрузки

При работе с каскадами важно учитывать визуальную обратную связь. Tom Select поддерживает добавление временных состояний.

countrySelect.on('change', function(value) {
  citySelect.disable();
  citySelect.clearOptions();

  citySelect.load(function(callback) {
    fetch(`/api/cities?country_id=${value}`)
      .then(res => res.json())
      .then(data => {
        citySelect.enable();
        callback(data);
      })
      .catch(() => {
        citySelect.enable();
        callback();
      });
  });
});

Состояния disable() и enable() предотвращают выбор некорректных значений во время загрузки данных.

Многоуровневые каскады

При расширении цепочки до трех и более уровней структура усложняется, но принцип остается тем же: каждый селект слушает изменения предыдущего.

<select id="country"></select>
<select id="region"></select>
<select id="city"></select>
countrySelect.on('change', function(countryId) {
  regionSelect.clear();
  regionSelect.clearOptions();

  regionSelect.load(function(callback) {
    fetch(`/api/regions?country_id=${countryId}`)
      .then(res => res.json())
      .then(data => callback(data))
      .catch(() => callback());
  });

  citySelect.clear();
  citySelect.clearOptions();
});
regionSelect.on('change', function(regionId) {
  citySelect.clear();
  citySelect.clearOptions();

  citySelect.load(function(callback) {
    fetch(`/api/cities?region_id=${regionId}`)
      .then(res => res.json())
      .then(data => callback(data))
      .catch(() => callback());
  });
});

Такая структура требует строгого контроля очистки данных на каждом уровне, иначе возможны несоответствия между выбранными значениями.

Предзагрузка и кэширование

Для уменьшения количества запросов можно использовать локальное кэширование. Особенно это эффективно при повторных выборах одного и того же родительского значения.

const cache = new Map();

function loadCities(countryId, callback) {
  if (cache.has(countryId)) {
    callback(cache.get(countryId));
    return;
  }

  fetch(`/api/cities?country_id=${countryId}`)
    .then(res => res.json())
    .then(data => {
      cache.set(countryId, data);
      callback(data);
    })
    .catch(() => callback());
}

Интеграция с Tom Select:

countrySelect.on('change', function(value) {
  citySelect.clear();
  citySelect.clearOptions();

  citySelect.load(function(callback) {
    loadCities(value, callback);
  });
});

Кэширование снижает нагрузку на сервер и уменьшает задержки при повторных запросах.

Синхронизация начальных значений

Часто требуется отобразить уже выбранные значения при загрузке страницы. В каскадных селектах порядок инициализации критичен: сначала загружается родитель, затем дочерние уровни.

countrySelect.setValue(initialCountryId);

countrySelect.on('load', function() {
  citySelect.load(function(callback) {
    fetch(`/api/cities?country_id=${initialCountryId}`)
      .then(res => res.json())
      .then(data => callback(data));
  });

  citySelect.setValue(initialCityId);
});

При этом важно учитывать, что установка значения до загрузки опций может привести к игнорированию выбранного состояния.

Управление зависимостями через общую функцию

При увеличении количества уровней каскада удобнее использовать централизованный обработчик зависимостей.

function loadDependent(select, url, params = {}) {
  select.clear();
  select.clearOptions();

  select.load(function(callback) {
    const query = new URLSearchParams(params).toString();

    fetch(`${url}?${query}`)
      .then(res => res.json())
      .then(data => callback(data))
      .catch(() => callback());
  });
}

Использование:

countrySelect.on('change', function(countryId) {
  loadDependent(regionSelect, '/api/regions', { country_id: countryId });
  loadDependent(citySelect, '/api/cities', { country_id: countryId });
});

Такой подход снижает дублирование кода и упрощает поддержку.

Обработка пустых значений

Каскадные селекты должны корректно реагировать на сброс родительского значения. В этом случае дочерние элементы также очищаются.

countrySelect.on('change', function(value) {
  if (!value) {
    regionSelect.clear();
    regionSelect.clearOptions();
    citySelect.clear();
    citySelect.clearOptions();
    return;
  }
});

Это предотвращает ситуацию, когда дочерние списки содержат устаревшие данные без контекста.

Оптимизация количества запросов

При частом изменении значений может возникать проблема избыточных запросов. Решение — debounce-логика.

function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

Применение:

countrySelect.on('change', debounce(function(value) {
  loadDependent(citySelect, '/api/cities', { country_id: value });
}, 300));

Это снижает нагрузку при быстрых переключениях пользователем.

Интеграция с серверной фильтрацией поиска

Tom Select поддерживает поиск, который можно связать с каскадной логикой. При этом поисковый запрос также учитывает родительский контекст.

citySelect.settings.load = function(query, callback) {
  const countryId = countrySelect.getValue();

  fetch(`/api/cities?country_id=${countryId}&q=${query}`)
    .then(res => res.json())
    .then(data => callback(data))
    .catch(() => callback());
};

Таким образом поиск внутри дочернего селекта всегда ограничен актуальным набором данных.

Управление ошибками и восстановление состояния

При сбое загрузки важно сохранять стабильность интерфейса. Tom Select позволяет безопасно завершать загрузку через callback без передачи данных.

citySelect.load(function(callback) {
  fetch('/api/cities')
    .then(res => res.json())
    .then(data => callback(data))
    .catch(() => callback([]));
});

Пустой массив обеспечивает корректное отображение пустого состояния без поломки UI.

Поведение при асинхронных гонках

При каскадных запросах возможно состояние гонки, когда старый запрос возвращается позже нового. Для защиты используется контроль актуальности запроса.

let requestId = 0;

countrySelect.on('change', function(value) {
  const currentRequest = ++requestId;

  citySelect.load(function(callback) {
    fetch(`/api/cities?country_id=${value}`)
      .then(res => res.json())
      .then(data => {
        if (currentRequest !== requestId) return;
        callback(data);
      })
      .catch(() => callback());
  });
});

Такой механизм гарантирует, что устаревшие ответы не перезапишут актуальные данные.