JavaScript 47 views

Dynamic Dropdown Menu with Fetch API

This JavaScript snippet creates a dynamic dropdown menu that fetches options from an API endpoint.

By TWC Team • Jan 25, 2026

Code

/**
 * Dynamic Dropdown Menu (Fetch API)
 * Fetches options from a remote JSON endpoint and populates a <select> safely and efficiently.
 * Usage: attach via initDynamicDropdown({ select, url, ... }) or call returned refresh() when needed.
 */
(() => {
  'use strict';

  const initDynamicDropdown = ({
    select,
    url,
    mapItem = ({ id, name }) => ({ value: String(id), label: String(name) }),
    placeholder = 'Select an option…',
    fetchOptions = {},
    onError = (error) => console.error('Dropdown fetch error:', error),
  }) => {
    if (!(select instanceof HTMLSelectElement)) {
      throw new TypeError('initDynamicDropdown: "select" must be an HTMLSelectElement.');
    }
    if (!url) throw new TypeError('initDynamicDropdown: "url" is required.');

    let currentController = null;

    const setLoadingState = (isLoading) => {
      select.disabled = isLoading;
      select.toggleAttribute('aria-busy', isLoading);
    };

    const clearOptions = () => {
      // Fast clear: resets options list without iterative DOM removal.
      select.options.length = 0;
    };

    const addOption = ({ value, label }, { disabled = false, selected = false } = {}) => {
      const option = new Option(label, value, selected, selected);
      option.disabled = disabled;
      select.add(option);
    };

    const populate = async () => {
      // Cancel any in-flight request to avoid race conditions (fast repeated refresh calls).
      currentController?.abort();
      currentController = new AbortController();

      setLoadingState(true);
      clearOptions();
      addOption({ value: '', label: placeholder }, { disabled: true, selected: true });

      try {
        const response = await fetch(url, {
          method: 'GET',
          headers: { Accept: 'application/json', ...(fetchOptions.headers || {}) },
          signal: currentController.signal,
          cache: fetchOptions.cache ?? 'no-store',
          ...fetchOptions,
        });

        if (!response.ok) throw new Error(`Request failed: ${response.status} ${response.statusText}`);

        const data = await response.json();
        if (!Array.isArray(data)) throw new TypeError('Expected the API to return an array of items.');

        // Build options in a fragment to minimize reflows/repaints.
        const fragment = document.createDocumentFragment();
        data
          .map(mapItem)
          .filter(({ value, label }) => value != null && label != null)
          .forEach(({ value, label }) => fragment.appendChild(new Option(label, value)));

        // If no valid items, keep placeholder and show a disabled empty message.
        if (!fragment.childNodes.length) {
          addOption({ value: '', label: 'No options available' }, { disabled: true, selected: true });
          return;
        }

        // Keep placeholder, then append fetched options.
        select.appendChild(fragment);
        select.disabled = false;
        select.removeAttribute('aria-busy');
      } catch (error) {
        if (error?.name === 'AbortError') return; // Swallow aborts; a newer request is in flight.
        onError(error);
        clearOptions();
        addOption({ value: '', label: 'Failed to load options' }, { disabled: true, selected: true });
      } finally {
        setLoadingState(false);
      }
    };

    // Auto-refresh when the select is connected (optional nice DX for SPA/DOM timing).
    if (select.isConnected) void populate();

    return { refresh: populate, destroy: () => currentController?.abort() };
  };

  // Example usage:
  // HTML: <select id="dynamicDropdown"></select>
  const selectEl = document.getElementById('dynamicDropdown');
  if (selectEl) {
    initDynamicDropdown({
      select: selectEl,
      url: 'https://api.example.com/items',
      // Optional: mapItem: ({ id, name }) => ({ value: id, label: name }),
      // Optional: onError: (e) => showToast(e.message),
    });
  }
})();
Back to Snippets