AsyncSingleSelect
Komponen dropdown single select berbasis async dengan lazy loading, pagination otomatis, dan debounce search. Cocok untuk data dinamis dari API dengan jumlah besar.
Overview
AsyncSingleSelect adalah komponen dropdown yang memuat data secara lazy (on-demand) dengan dukungan infinite scroll pagination. Komponen ini standalone (tidak terintegrasi dengan form library) dan fully controlled.
Features
- ✅ Async Data Loading: Memuat data dari API secara lazy
- ✅ Pagination: Infinite scroll untuk load data bertahap
- ✅ Debounce Search: Delay 500ms untuk optimasi API call
- ✅ Clearable: Opsi untuk clear selection
- ✅ Loading States: Visual feedback saat loading
- ✅ Controlled Component: Full control dari parent component
- ✅ Lightweight: Tidak bergantung pada form library
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" | Placeholder text saat belum ada selection |
selected | Option | null | - | Required. Currently selected option |
onChange | (option: Option | null) => void | - | Required. Callback saat selection berubah |
loadOptions | (input: string, page: number) => Promise<Option[]> | - | Required. Function untuk fetch data |
disabled | boolean | false | Disable component |
clearable | boolean | true | Tampilkan tombol clear |
Option Interface
interface Option {
label: string; // Displayed text
value: string; // Unique identifier
}
loadOptions Function
type LoadOptions = (
input: string, // Search query
page: number // Page number (dimulai dari 1)
) => Promise<Option[]>;
Usage
Basic Usage
import { useState } from "react";
import { AsyncSingleSelect } from "@/components/ui/async-single-select";
function UserSelector() {
const [selectedUser, setSelectedUser] = useState<Option | null>(null);
const loadUsers = async (search: string, page: number) => {
const response = await fetch(
`/api/users?search=${search}&page=${page}&limit=10`
);
const data = await response.json();
return data.users.map(user => ({
label: user.name,
value: user.id
}));
};
return (
<AsyncSingleSelect
placeholder="Pilih User"
selected={selectedUser}
onChange={setSelectedUser}
loadOptions={loadUsers}
/>
);
}
With Custom API
const loadProducts = async (search: string, page: number) => {
const params = new URLSearchParams({
q: search,
page: page.toString(),
limit: '10'
});
const response = await fetch(`/api/products?${params}`);
const { items } = await response.json();
return items.map(product => ({
label: `${product.name} - Rp ${product.price}`,
value: product.id
}));
};
<AsyncSingleSelect
placeholder="Cari Produk"
selected={selectedProduct}
onChange={setSelectedProduct}
loadOptions={loadProducts}
/>
Non-Clearable
<AsyncSingleSelect
placeholder="Pilih Kategori (Required)"
selected={selectedCategory}
onChange={setSelectedCategory}
loadOptions={loadCategories}
clearable={false} // Tidak bisa di-clear
/>
Disabled State
<AsyncSingleSelect
placeholder="Pilih Opsi"
selected={selectedOption}
onChange={setSelectedOption}
loadOptions={loadOptions}
disabled={isSubmitting} // Disable saat submit
/>
Advanced Examples
With Error Handling
const loadOptions = async (search: string, page: number) => {
try {
const response = await fetch(`/api/data?search=${search}&page=${page}`);
if (!response.ok) {
throw new Error('Failed to load data');
}
const data = await response.json();
return data.items.map(item => ({
label: item.name,
value: item.id
}));
} catch (error) {
console.error('Load options error:', error);
return []; // Return empty array 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.map(item => ({
label: item.name,
value: item.id
}));
};
With TypeScript Generics (Extended)
interface User {
id: string;
name: string;
email: string;
}
const [selectedUser, setSelectedUser] = useState<Option | null>(null);
const loadUsers = async (search: string, page: number): Promise<Option[]> => {
const response = await fetch<{ users: User[] }>(
`/api/users?search=${search}&page=${page}`
);
const { users } = await response.json();
return users.map(user => ({
label: `${user.name} (${user.email})`,
value: user.id
}));
};
How It Works
1. Debounce Mechanism
Search input di-debounce 500ms untuk mengurangi API calls:
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedInput(inputValue);
}, 500);
return () => clearTimeout(timer);
}, [inputValue]);
2. Pagination
Infinite scroll yang auto-load saat scroll mendekati bottom:
onScroll={(e) => {
const target = e.currentTarget;
if (
target.scrollTop + target.clientHeight >= target.scrollHeight - 10 &&
!loading
) {
fetchOptions(debouncedInput, page + 1);
}
}}
3. Loading States
Ada 2 loading states:
inputLoading: Saat user mengetik (debouncing)loading: Saat fetch data (initial atau pagination)
Customization
Custom Styling
// Modify button styling
<button
className="custom-trigger-class" // Add your custom class
>
{/* ... */}
</button>
Custom Placeholder
<AsyncSingleSelect
placeholder="🔍 Ketik untuk mencari..."
// ...
/>
Custom Clear Icon
Edit source code di handleClear section:
{clearable && selected && !disabled && (
<X
className="h-4 w-4 text-red-500" // Custom color
onClick={handleClear}
/>
)}
Performance Optimization
1. Memoize loadOptions
const loadOptions = useCallback(async (search: string, page: number) => {
const response = await fetch(`/api/data?search=${search}&page=${page}`);
const data = await response.json();
return data.items;
}, []);
2. Implement Caching
const cache = useRef<Map<string, Option[]>>(new Map());
const loadOptions = async (search: string, page: number) => {
const key = `${search}-${page}`;
if (cache.current.has(key)) {
return cache.current.get(key)!;
}
const response = await fetch(`/api/data?search=${search}&page=${page}`);
const data = await response.json();
cache.current.set(key, data.items);
return data.items;
};
3. Pagination Limit
Sesuaikan limit dengan kebutuhan:
// 10 items per page (default)
const response = await fetch(`/api/data?limit=10&page=${page}`);
// 20 items per page (faster pagination)
const response = await fetch(`/api/data?limit=20&page=${page}`);
Best Practices
1. Always Handle Errors
const loadOptions = async (search: string, page: number) => {
try {
// ... fetch logic
} catch (error) {
console.error(error);
return []; // Return empty array instead of throwing
}
};
2. Use Controlled Component Pattern
// ✅ Correct
const [selected, setSelected] = useState(null);
<AsyncSingleSelect selected={selected} onChange={setSelected} />
// ❌ Wrong - Uncontrolled
<AsyncSingleSelect defaultSelected={value} />
3. Debounce is Built-in
Jangan tambahkan debounce eksternal, sudah ada internal debounce 500ms.
// ❌ Don't do this
const debouncedLoad = debounce(loadOptions, 500);
// ✅ Just use it directly
<AsyncSingleSelect loadOptions={loadOptions} />
4. Pagination Page Start from 1
Backend API harus menerima page dimulai dari 1:
// Backend API should handle:
// GET /api/data?page=1 (first page)
// GET /api/data?page=2 (second page)
Comparison with React Hook Form Version
| Feature | AsyncSingleSelect | AsyncSingleSelectRHF |
|---|---|---|
| Form Integration | ❌ Manual | ✅ React Hook Form |
| Validation | ❌ Manual | ✅ Built-in |
| Error Messages | ❌ Manual | ✅ Automatic |
| Code Complexity | Simple | Medium |
| Use Case | Simple forms, custom forms | Complex forms with validation |
Migration to RHF Version
Jika butuh form validation, migrate ke AsyncSingleSelectRHF:
// Before (Standalone)
<AsyncSingleSelect
selected={selected}
onChange={setSelected}
loadOptions={loadOptions}
/>
// After (React Hook Form)
<AsyncSingleSelectRHF
name="userId"
control={control}
loadOptions={loadOptions}
/>
Troubleshooting
Data Tidak Muncul
Problem: Dropdown kosong meskipun API mengembalikan data.
Solution: Pastikan API mengembalikan array Option[]:
// ✅ Correct format
[
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: "2" }
]
// ❌ Wrong format
[
{ name: "Option 1", id: "1" } // Harus di-map dulu
]
Pagination Tidak Jalan
Problem: Scroll ke bawah tidak load data baru.
Solution: Pastikan API mengembalikan data sesuai page:
// Backend harus handle pagination
GET /api/data?page=1 // Return items 1-10
GET /api/data?page=2 // Return items 11-20
Loading Terlalu Lama
Problem: Debounce terlalu lama.
Solution: Kurangi delay debounce di source code:
// Dari 500ms ke 300ms
setTimeout(() => {
setDebouncedInput(inputValue);
}, 300);
Browser Compatibility
- ✅ Modern browsers (Chrome, Firefox, Safari, Edge)
- ✅ Requires ES6+ support
- ✅ Mobile responsive
Related Components
- AsyncSingleSelectRHF - React Hook Form version
- AsyncMultiSelect - Multi-selection version
- AsyncMultiSelectRHF - Multi-select with RHF
Version History
- v1.0.0: Initial release dengan async loading
- v1.1.0: Added pagination support
- v1.2.0: Added debounce search
- v1.3.0: Added clearable option
Contributing
Untuk improvement atau bug fixes, silakan submit PR dengan:
- Deskripsi perubahan
- Test coverage untuk edge cases
- Update dokumentasi jika ada perubahan API