Skip to main content

AsyncMultiSelect

Komponen dropdown multi-select berbasis async dengan lazy loading, pagination, debounce search, dan chips display untuk selected items. Cocok untuk multiple selection dari data API yang besar.

Overview​

AsyncMultiSelect adalah komponen dropdown yang mendukung multiple selection dengan data yang dimuat secara async dari API. Komponen ini standalone (tidak terintegrasi dengan form library) dan menampilkan selected items sebagai chips/badges.

Features​

  • ✅ Multiple Selection: Pilih banyak items sekaligus
  • ✅ Chips Display: Selected items ditampilkan sebagai badges
  • ✅ Async Lazy Loading: Load data on-demand dari API
  • ✅ Infinite Scroll Pagination: Auto-load saat scroll
  • ✅ Debounce Search: Delay 500ms untuk optimasi
  • ✅ Remove Chips: Hapus individual selected items
  • ✅ Throttled Scroll: Optimized scroll handling (300ms throttle)
  • ✅ Loading States: Visual feedback untuk UX
  • ✅ Controlled Component: Full control dari parent

Installation​

Dependencies​

npm install lucide-react

Required Components​

// shadcn/ui atau custom UI components
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty } from "@/components/ui/command";

API Reference​

Props​

PropTypeDefaultDescription
placeholderstring"Pilih beberapa"Placeholder text saat kosong
selectedOption[]-Required. Array of selected options
onChange(options: Option[]) => void-Required. Callback saat selection berubah
loadOptions(input: string, page: number) => Promise<Option[]>-Required. Function fetch data
disabledbooleanfalseDisable component
classNamestring-Custom CSS class untuk trigger
pageSizenumber10Expected items per page

Option Interface​

interface Option {
label: string; // Displayed text
value: string; // Unique identifier
}

Usage​

Basic Usage​

import { useState } from "react";
import { AsyncMultiSelect } from "@/components/ui/async-multi-select";

function TagSelector() {
const [selectedTags, setSelectedTags] = useState<Option[]>([]);

const loadTags = async (search: string, page: number) => {
const response = await fetch(
`/api/tags?search=${search}&page=${page}&limit=10`
);
const data = await response.json();

return data.tags.map(tag => ({
label: tag.name,
value: tag.id
}));
};

return (
<div>
<label>Select Tags</label>
<AsyncMultiSelect
placeholder="Pilih tags"
selected={selectedTags}
onChange={setSelectedTags}
loadOptions={loadTags}
/>

{/* Display selected count */}
<p className="text-sm text-gray-500 mt-2">
{selectedTags.length} tags selected
</p>
</div>
);
}

With Initial Values​

const [selected, setSelected] = useState<Option[]>([
{ label: "React", value: "1" },
{ label: "TypeScript", value: "2" }
]);

<AsyncMultiSelect
selected={selected}
onChange={setSelected}
loadOptions={loadOptions}
/>

With Custom Page Size​

<AsyncMultiSelect
placeholder="Pilih users"
selected={selectedUsers}
onChange={setSelectedUsers}
loadOptions={loadUsers}
pageSize={20} // Load 20 items per page
/>

Disabled State​

<AsyncMultiSelect
placeholder="Tidak bisa dipilih"
selected={selected}
onChange={setSelected}
loadOptions={loadOptions}
disabled={isSubmitting}
/>

Advanced Examples​

With Submit Handler​

function FormWithTags() {
const [selectedTags, setSelectedTags] = useState<Option[]>([]);

const handleSubmit = () => {
const tagIds = selectedTags.map(tag => tag.value);
console.log('Selected tag IDs:', tagIds);

// Submit to API
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ tags: tagIds })
});
};

return (
<div>
<AsyncMultiSelect
selected={selectedTags}
onChange={setSelectedTags}
loadOptions={loadTags}
/>
<button onClick={handleSubmit}>Submit</button>
</div>
);
}

With Error Handling​

const loadOptions = async (search: string, page: number) => {
try {
const response = await fetch(
`/api/options?search=${search}&page=${page}&limit=10`
);

if (!response.ok) {
throw new Error('Failed to load');
}

const data = await response.json();
return data.items.map(item => ({
label: item.name,
value: item.id
}));
} catch (error) {
console.error('Load error:', error);
return []; // Return empty on error
}
};

With Authentication​

const loadOptions = async (search: string, page: number) => {
const token = localStorage.getItem('authToken');

const response = await fetch(
`/api/data?search=${search}&page=${page}`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);

const data = await response.json();
return data.items;
};

With TypeScript​

interface User {
id: string;
name: string;
email: string;
}

const [selectedUsers, setSelectedUsers] = useState<Option[]>([]);

const loadUsers = async (
search: string,
page: number
): Promise<Option[]> => {
const response = await fetch(`/api/users?search=${search}&page=${page}`);
const { users }: { users: User[] } = await response.json();

return users.map(user => ({
label: `${user.name} (${user.email})`,
value: user.id
}));
};

How It Works​

Input di-debounce 500ms untuk mengurangi API calls:

React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedInput(inputValue);
}, 500);
return () => clearTimeout(timer);
}, [inputValue]);

2. Throttled Scroll​

Scroll events di-throttle 300ms untuk performance:

const lastScrollTimeRef = React.useRef(0);

onScroll={(e) => {
const now = Date.now();

// Skip jika belum 300ms
if (now - lastScrollTimeRef.current < 300) {
return;
}

lastScrollTimeRef.current = now;
// ... fetch next page
}}

3. Pagination Logic​

Auto-detect end of data berdasarkan pageSize:

const isLastPage = 
newOptions.length === 0 ||
newOptions.length < pageSize;

setHasMorePages(!isLastPage);

4. Duplicate Prevention​

Filter duplicate options saat append:

setOptions((prev) => {
const existingValues = new Set(prev.map(opt => opt.value));
const newUniqueOptions = newOptions.filter(
opt => !existingValues.has(opt.value)
);
return [...prev, ...newUniqueOptions];
});

Customization​

Custom Chip Styling​

// Modify selected chip display
<span className="flex items-center bg-blue-100 text-blue-800 px-2 py-0.5 rounded text-xs">
{opt?.label}
<X className="ml-1 h-3 w-3 cursor-pointer" />
</span>

// Custom colors
<span className="bg-green-100 text-green-800 px-2 py-1 rounded-full">
{opt?.label}
</span>

Custom Placeholder​

<AsyncMultiSelect
placeholder="🏷️ Pilih tags yang relevan..."
// ...
/>

Custom Loading Message​

Edit source code:

{inputLoading && (
<div className="flex items-center justify-center py-2">
<Loader2 className="animate-spin h-4 w-4" />
<span className="ml-2 text-sm">
Sedang mencari data... {/* Custom message */}
</span>
</div>
)}

Performance Optimization​

1. Memoize loadOptions​

const loadOptions = useCallback(
async (search: string, page: number) => {
const response = await fetch(`/api/data?search=${search}&page=${page}`);
return response.json();
},
[] // Dependencies
);

2. Limit Initial Load​

// Load hanya 5 items di awal untuk quick UX
<AsyncMultiSelect
pageSize={5}
// ...
/>

3. Virtualization (For Large Lists)​

Jika list sangat panjang, consider menggunakan react-window atau react-virtualized.

4. Debounce Adjustment​

Kurangi debounce untuk faster response:

// Edit di source code
setTimeout(() => {
setDebouncedInput(inputValue);
}, 300); // Dari 500ms ke 300ms

Best Practices​

1. Always Handle Empty State​

const loadOptions = async (search: string, page: number) => {
try {
const data = await fetchData(search, page);
return data.items || []; // Always return array
} catch {
return []; // Return empty on error
}
};

2. Validate Selected Items​

const handleChange = (options: Option[]) => {
if (options.length > 10) {
alert('Maximum 10 items');
return;
}
setSelected(options);
};

3. Reset on Unmount​

useEffect(() => {
return () => {
setSelected([]); // Cleanup
};
}, []);

4. Consistent Option Format​

// ✅ Correct
{ label: "Item 1", value: "1" }

// ❌ Wrong
{ name: "Item 1", id: "1" } // Harus di-transform dulu

Internal State Management​

Component ini menggunakan multiple refs untuk optimize performance:

// Refs yang digunakan:
fetchTimeoutRef // Untuk clear pending fetch
lastFetchRef // Prevent duplicate API calls
isMountedRef // Cleanup handler
isScrollFetchingRef // Prevent multiple scroll fetch
lastScrollTimeRef // Throttle scroll events
commandListRef // Scroll container reference

Comparison Table​

FeatureAsyncMultiSelectAsyncMultiSelectRHF
Form Integration❌ Manual✅ React Hook Form
Validation❌ Manual✅ Built-in
Chips Display✅ Yes✅ Yes
Removable Items✅ Yes✅ Yes (with control)
Pagination✅ Yes✅ Yes + Caching
Code ComplexityMediumAdvanced
Use CaseSimple multi-selectForm with validation

Troubleshooting​

Chips Tidak Muncul​

Problem: Selected items tidak ditampilkan sebagai chips.

Solution: Pastikan selected adalah array dan memiliki format yang benar:

// ✅ Correct
[
{ label: "Item 1", value: "1" },
{ label: "Item 2", value: "2" }
]

// ❌ Wrong
["1", "2"] // Harus object dengan label & value

Duplicate Items Muncul​

Problem: Item yang sama muncul berkali-kali di list.

Solution: Component sudah handle duplicate prevention. Pastikan backend tidak return duplicate data.

Pagination Tidak Load​

Problem: Scroll tidak trigger load next page.

Solution:

  1. Pastikan API return data sesuai pageSize
  2. Check hasMorePages flag di component state
  3. Verify backend pagination logic

Performance Issue​

Problem: Component lambat saat banyak selected items.

Solution:

  1. Limit maximum selected items
  2. Use smaller pageSize
  3. Implement virtualization untuk large lists

Migration Guide​

From Static MultiSelect​

// Before (Static)
const options = [
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: "2" }
];

<MultiSelect
options={options}
value={selected}
onChange={setSelected}
/>

// After (Async)
const loadOptions = async (search: string, page: number) => {
const response = await fetch(`/api/options?search=${search}&page=${page}`);
return response.json();
};

<AsyncMultiSelect
selected={selected}
onChange={setSelected}
loadOptions={loadOptions}
/>

To React Hook Form Version​

// Upgrade ke RHF version untuk form validation
import { AsyncMultiSelectRHF } from './async-multi-select-rhf';

<AsyncMultiSelectRHF
name="tags"
control={control}
loadOptions={loadOptions}
/>

Browser Compatibility​

  • ✅ Modern browsers (Chrome, Firefox, Safari, Edge)
  • ✅ Requires ES6+ (Set, Map, async/await)
  • ✅ Mobile responsive
  • ✅ Touch-friendly scroll

Version History​

  • v1.0.0: Initial release
  • v1.1.0: Added pagination support
  • v1.2.0: Added throttled scroll (300ms)
  • v1.3.0: Added duplicate prevention
  • v1.4.0: Improved loading states

Known Limitations​

  1. No Caching: Setiap kali dropdown dibuka, data di-fetch ulang
  2. No Virtual Scroll: Dapat lambat jika thousands of items
  3. Fixed Chip Style: Chips style tidak customizable via props

Contributing​

Untuk improvement:

  1. Add caching mechanism (similar to RHF version)
  2. Add virtual scroll support
  3. Make chip styling customizable
  4. Add max selection limit prop
  5. Add custom empty state component