Dynamic Dropdown Menu with Fetch API
This JavaScript snippet creates a dynamic dropdown menu that fetches options from an API endpoint.
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),
});
}
})();