Динамическая загрузка зависимых данных

Принцип зависимых списков

Зависимые списки (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());
  });
});

Поведение:

  • при изменении страны очищаются текущие города;
  • загружается новый набор данных;
  • обновляется список options без пересоздания компонента.

Использование блокировки состояния

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

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);
}

Приоритетное правило:

  • сначала загружается родитель;
  • затем подгружается зависимый список;
  • затем устанавливается значение.

Управление race conditions

Асинхронные запросы могут завершаться в произвольном порядке, что приводит к некорректному отображению данных. Решение — контроль актуальности запроса.

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
    }));
}

Использование нормализации снижает риск некорректного отображения и ошибок выбора.


Контроль визуальной согласованности

При частых обновлениях данных важно поддерживать стабильное состояние интерфейса. Используется последовательная очистка и обновление:

  • очистка выбранного значения;
  • очистка options;
  • блокировка интерфейса;
  • загрузка данных;
  • разблокировка.

Эта последовательность предотвращает появление «висящих» значений и некорректных зависимостей между селектами.