Skip to main content

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​

PropTypeDefaultDescription
placeholderstring"Pilih"Placeholder text saat belum ada selection
selectedOption | 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
disabledbooleanfalseDisable component
clearablebooleantrueTampilkan 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​

FeatureAsyncSingleSelectAsyncSingleSelectRHF
Form Integration❌ Manual✅ React Hook Form
Validation❌ Manual✅ Built-in
Error Messages❌ Manual✅ Automatic
Code ComplexitySimpleMedium
Use CaseSimple forms, custom formsComplex 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

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:

  1. Deskripsi perubahan
  2. Test coverage untuk edge cases
  3. Update dokumentasi jika ada perubahan API