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