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