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
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | "Pilih beberapa" | Placeholder text saat kosong |
selected | Option[] | - | 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 |
disabled | boolean | false | Disable component |
className | string | - | Custom CSS class untuk trigger |
pageSize | number | 10 | Expected 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
1. Debounce Search
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
| Feature | AsyncMultiSelect | AsyncMultiSelectRHF |
|---|---|---|
| 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 Complexity | Medium | Advanced |
| Use Case | Simple multi-select | Form 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:
- Pastikan API return data sesuai
pageSize - Check
hasMorePagesflag di component state - Verify backend pagination logic
Performance Issue
Problem: Component lambat saat banyak selected items.
Solution:
- Limit maximum selected items
- Use smaller
pageSize - 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
Related Components
- AsyncMultiSelectRHF - React Hook Form version
- AsyncSingleSelect - Single selection version
- AsyncSingleSelectRHF - Single select with RHF
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
- No Caching: Setiap kali dropdown dibuka, data di-fetch ulang
- No Virtual Scroll: Dapat lambat jika thousands of items
- Fixed Chip Style: Chips style tidak customizable via props
Contributing
Untuk improvement:
- Add caching mechanism (similar to RHF version)
- Add virtual scroll support
- Make chip styling customizable
- Add max selection limit prop
- Add custom empty state component