Мастер-детальные отношения

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

Мастер-детальная структура строится вокруг двух типов селектов:

  • Master (главный селект) — источник состояния
  • Detail (зависимый селект) — потребитель состояния

Ключевая особенность заключается в том, что detail не имеет собственного фиксированного набора опций. Его состояние формируется динамически на основе выбора master.

В контексте Tom Select это достигается через программное управление опциями (options), очистку значений (clear()), перезагрузку (clearOptions()) и асинхронное обновление данных.

Базовая реализация зависимости

Простейшая схема включает два экземпляра Tom Select:

const master = new TomSelect("#country");

const detail = new TomSelect("#city", {
  create: false,
  placeholder: "Выберите город"
});

Далее добавляется обработчик изменения master:

master.on("change", (value) => {
  detail.clear();
  detail.clearOptions();

  const cities = getCitiesByCountry(value);

  detail.addOptions(
    cities.map(city => ({
      value: city.id,
      text: city.name
    }))
  );

  detail.refreshOptions(false);
});

Функция getCitiesByCountry выступает источником бизнес-логики и может быть как локальной, так и серверной.

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

В реальных системах данные редко хранятся локально. Чаще используется API-запрос:

master.on("change", async (countryId) => {
  detail.clear();
  detail.clearOptions();

  const response = await fetch(`/api/cities?country=${countryId}`);
  const cities = await response.json();

  detail.addOptions(
    cities.map(c => ({
      value: c.id,
      text: c.name
    }))
  );

  detail.refreshOptions(false);
});

Здесь критично учитывать состояние загрузки, чтобы избежать неконсистентного UI.

Управление состоянием при смене master

Смена значения master требует полного сброса dependent-селекта:

  • очистка выбранного значения
  • удаление старых опций
  • блокировка взаимодействия во время загрузки (опционально)
master.on("change", async (countryId) => {
  detail.disable();
  detail.clear();
  detail.clearOptions();

  const cities = await fetchCities(countryId);

  detail.addOptions(cities);
  detail.refreshOptions(false);

  detail.enable();
});

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

Каскадная цепочка из нескольких уровней

Мастер-детальная модель не ограничивается двумя уровнями. Возможна цепочка:

  • Страна → Регион → Город → Район
country.on("change", async (countryId) => {
  region.clear();
  city.clear();

  const regions = await fetch(`/api/regions?country=${countryId}`).then(r => r.json());

  region.addOptions(regions);
  region.refreshOptions(false);
});

region.on("change", async (regionId) => {
  city.clear();

  const cities = await fetch(`/api/cities?region=${regionId}`).then(r => r.json());

  city.addOptions(cities);
  city.refreshOptions(false);
});

Каждый уровень является одновременно master для следующего и detail для предыдущего.

Синхронизация состояния при инициализации

Особая сложность возникает при предзаполненных формах (edit mode). Необходимо восстановить всю цепочку зависимостей:

async function initForm(data) {
  await master.setValue(data.country);

  const regions = await fetchRegions(data.country);
  region.addOptions(regions);

  await region.setValue(data.region);

  const cities = await fetchCities(data.region);
  city.addOptions(cities);

  await city.setValue(data.city);
}

Ключевой принцип — последовательная инициализация сверху вниз.

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

Проблема частых изменений master решается через debounce:

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

master.on("change", debounce(async (value) => {
  detail.clear();
  const data = await fetchData(value);
  detail.addOptions(data);
  detail.refreshOptions(false);
}, 300));

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

Кэширование зависимых данных

При повторных выборах одного и того же master-значения можно избежать повторных запросов:

const cache = new Map();

async function getCities(countryId) {
  if (cache.has(countryId)) {
    return cache.get(countryId);
  }

  const data = await fetch(`/api/cities?country=${countryId}`)
    .then(r => r.json());

  cache.set(countryId, data);
  return data;
}

Обработка ошибок в цепочке зависимостей

При сбое загрузки важно не оставлять detail в неконсистентном состоянии:

master.on("change", async (value) => {
  try {
    detail.disable();
    detail.clearOptions();

    const data = await fetchData(value);

    detail.addOptions(data);
    detail.refreshOptions(false);
  } catch (e) {
    detail.clear();
    detail.addOption({ value: "", text: "Ошибка загрузки" });
  } finally {
    detail.enable();
  }
});

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

Мастер-детальная модель часто используется не только для UI, но и для формирования серверных фильтров:

form.addEventListener("submit", (e) => {
  e.preventDefault();

  const params = new URLSearchParams({
    country: master.getValue(),
    city: detail.getValue()
  });

  fetch(`/api/search?${params}`);
});

Расширенные сценарии зависимостей

Условная логика

Зависимости могут быть не линейными, а условными:

master.on("change", (value) => {
  if (value === "remote") {
    detail.disable();
  } else {
    detail.enable();
  }
});

Мультизависимость

Detail может зависеть от нескольких master-селектов:

function updateCities() {
  const country = countrySelect.getValue();
  const region = regionSelect.getValue();

  fetch(`/api/cities?country=${country}&region=${region}`)
    .then(r => r.json())
    .then(data => {
      citySelect.clearOptions();
      citySelect.addOptions(data);
      citySelect.refreshOptions(false);
    });
}

countrySelect.on("change", updateCities);
regionSelect.on("change", updateCities);

Управление UX при загрузке данных

Для улучшения пользовательского опыта применяются состояния загрузки:

  • временное отключение detail
  • placeholder “Загрузка…”
  • визуальная индикация
detail.disable();
detail.setValue("");
detail.settings.placeholder = "Загрузка...";
detail.refreshOptions(false);

После загрузки placeholder возвращается к нормальному состоянию.

Типичные ошибки реализации

  • отсутствие очистки detail при смене master
  • сохранение устаревшего value
  • параллельные запросы без отмены предыдущих
  • отсутствие обработки пустого master значения
  • добавление опций без refreshOptions(false)

Эти ошибки приводят к рассинхронизации UI и данных.

Стабильная модель состояния

Корректная модель состояния строится вокруг трех операций:

  • reset (очистка)
  • load (загрузка)
  • bind (установка значения)
function resetSelect(select) {
  select.clear();
  select.clearOptions();
}

async function loadSelect(select, data) {
  select.addOptions(data);
  select.refreshOptions(false);
}

async function bindSelect(select, value) {
  await select.setValue(value);
}

Комбинация этих операций формирует устойчивую каскадную систему зависимостей между селектами.