AsyncSingleSelectRHF
Komponen dropdown single select berbasis async dengan integrasi React Hook Form, mendukung lazy loading, pagination otomatis, validation, dan error handling.
Overview
AsyncSingleSelectRHF (React Hook Form version) adalah wrapper dari AsyncSingleSelect yang terintegrasi penuh dengan React Hook Form. Komponen ini cocok untuk form kompleks dengan validation rules, error messages, dan state management otomatis.
Features
- ✅ React Hook Form Integration: Full integration dengan RHF
- ✅ Async Data Loading: Lazy load data dari API
- ✅ Pagination with Caching: Infinite scroll dengan intelligent caching
- ✅ Debounce Search: Optimized search dengan debounce 400ms
- ✅ Form Validation: Built-in validation support
- ✅ Error Handling: Auto error display dari RHF
- ✅ IntersectionObserver: Smooth infinite scroll
- ✅ TypeScript Generics: Full type safety
Installation
Dependencies
npm install react-hook-form 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
FormAsyncSingleSelect Props
| Prop | Type | Default | Description |
|---|---|---|---|
name | Path<TFieldValues> | - | Required. Field name di form |
control | Control<TFieldValues> | - | Required. RHF control object |
loadOptions | (query: string, page: number) => Promise<TOption[]> | - | Required. Function fetch data |
placeholder | string | "Pilih..." | Placeholder text |
noDataLabel | string | "Tidak ditemukan" | Empty state text |
searchingLabel | string | "Mencari..." | Searching state text |
loadingLabel | string | "Memuat..." | Loading state text |
disabled | boolean | false | Disable select |
pageSize | number | 10 | Items per page |
getOptionLabel | (option: TOption) => string | o => o.label | Extract label dari option |
getOptionValue | (option: TOption) => string | o => o.value | Extract value dari option |
className | string | - | Custom CSS class |
debounceMs | number | 400 | Debounce delay (ms) |
allowClear | boolean | true | Show clear button |
maxHeight | number | 260 | Max height dropdown (px) |
rules | RegisterOptions | - | RHF validation rules |
mapFormToOption | (formValue: any) => TOption | null | - | Transform form value ke option |
mapOptionToForm | (option: TOption | null) => any | - | Transform option ke form value |
DefaultOption Interface
interface DefaultOption {
label: string;
value: string;
[extra: string]: any; // Extra properties
}
loadOptions Return Type
// Bisa return array biasa
Promise<TOption[]>
// Atau object dengan hasMore flag
Promise<{
items: TOption[];
hasMore?: boolean; // Explicit pagination flag
}>
Usage
Basic Usage with React Hook Form
import { useForm } from "react-hook-form";
import { FormAsyncSingleSelect } from "@/components/ui/async-single-select-rhf";
interface FormData {
userId: string | null;
}
function UserForm() {
const { control, handleSubmit } = useForm<FormData>({
defaultValues: {
userId: 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
}));
};
const onSubmit = (data: FormData) => {
console.log('Selected user:', data.userId);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormAsyncSingleSelect
name="userId"
control={control}
placeholder="Pilih User"
loadOptions={loadUsers}
/>
<button type="submit">Submit</button>
</form>
);
}
With Validation Rules
<FormAsyncSingleSelect
name="categoryId"
control={control}
placeholder="Pilih Kategori"
loadOptions={loadCategories}
rules={{
required: "Kategori wajib dipilih",
validate: (value) => {
if (!value) return "Silakan pilih kategori";
return true;
}
}}
/>
{/* Display error message */}
{errors.categoryId && (
<p className="text-sm text-red-500">
{errors.categoryId.message}
</p>
)}
With Custom TypeScript Interface
interface Product {
id: string;
name: string;
price: number;
category: string;
}
interface FormData {
product: Product | null;
}
const { control } = useForm<FormData>();
const loadProducts = async (
search: string,
page: number
): Promise<Product[]> => {
const response = await fetch(
`/api/products?search=${search}&page=${page}`
);
const data = await response.json();
return data.products;
};
<FormAsyncSingleSelect<FormData, Product>
name="product"
control={control}
loadOptions={loadProducts}
getOptionLabel={(p) => `${p.name} - Rp ${p.price}`}
getOptionValue={(p) => p.id}
pageSize={15}
/>
With Explicit hasMore Flag
const loadOptions = async (search: string, page: number) => {
const response = await fetch(
`/api/data?search=${search}&page=${page}&limit=10`
);
const data = await response.json();
return {
items: data.items.map(item => ({
label: item.name,
value: item.id
})),
hasMore: data.hasMore // Explicit flag dari backend
};
};
Advanced Examples
With Value Transformation
// Form menyimpan ID (string), tapi component butuh object
interface FormData {
cityId: string; // Only store ID
}
interface City {
id: string;
name: string;
}
<FormAsyncSingleSelect<FormData, City>
name="cityId"
control={control}
loadOptions={loadCities}
getOptionLabel={(c) => c.name}
getOptionValue={(c) => c.id}
// Transform stored ID to City object
mapFormToOption={(id: string) => {
if (!id) return null;
// Fetch or find city by ID
return cities.find(c => c.id === id) || null;
}}
// Transform City object back to ID
mapOptionToForm={(city: City | null) => city?.id || null}
/>
With Default Value
const { control } = useForm<FormData>({
defaultValues: {
userId: { label: "John Doe", value: "123" }
}
});
<FormAsyncSingleSelect
name="userId"
control={control}
loadOptions={loadUsers}
/>
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'
}
}
);
if (!response.ok) {
throw new Error('Failed to load data');
}
const data = await response.json();
return data.items;
};
With Loading State
function FormWithLoading() {
const { control, formState: { isSubmitting } } = useForm();
return (
<FormAsyncSingleSelect
name="userId"
control={control}
loadOptions={loadUsers}
disabled={isSubmitting} // Disable saat submit
/>
);
}
How It Works
1. Caching Mechanism
Komponen menggunakan Map-based cache untuk menghindari duplicate API calls:
const cacheRef = React.useRef<Map<string, TOption[]>>(new Map());
const cacheKey = `${query}:::${page}`;
// Check cache sebelum fetch
if (cacheRef.current.has(cacheKey)) {
return cacheRef.current.get(cacheKey)!;
}
// Store to cache setelah fetch
cacheRef.current.set(cacheKey, data);
2. Debounce Search
Input di-debounce untuk mengurangi API calls:
const debouncedQuery = useDebounce(input, 400);
// API hanya dipanggil setelah user berhenti mengetik 400ms
useAsyncPaginatedOptions({
query: debouncedQuery,
// ...
});
3. IntersectionObserver for Infinite Scroll
Menggunakan IntersectionObserver untuk smooth pagination:
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
fetchNext(); // Auto-load next page
}
},
{ threshold: 0.1 }
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, loading, fetchNext]);
4. Abort Controller
Request yang tidak diperlukan di-cancel untuk performance:
const abortRef = React.useRef<AbortController | null>(null);
// Cancel previous request
abortRef.current?.abort();
// Create new controller
const controller = new AbortController();
abortRef.current = controller;
Form Integration Patterns
With FormProvider
import { FormProvider, useForm } from "react-hook-form";
function MyForm() {
const methods = useForm<FormData>();
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<FormAsyncSingleSelect
name="field"
control={methods.control}
loadOptions={loadOptions}
/>
</form>
</FormProvider>
);
}
With Controller (Manual)
import { Controller, useForm } from "react-hook-form";
import { AsyncSingleSelect } from "./async-single-select";
function ManualForm() {
const { control } = useForm();
return (
<Controller
name="field"
control={control}
render={({ field }) => (
<AsyncSingleSelect
value={field.value}
onChange={field.onChange}
loadOptions={loadOptions}
/>
)}
/>
);
}
Best Practices
1. Use Memoized loadOptions
const loadOptions = useCallback(
async (search: string, page: number) => {
// ... fetch logic
},
[] // Dependencies
);
2. Implement Error Boundaries
<ErrorBoundary>
<FormAsyncSingleSelect
name="field"
control={control}
loadOptions={loadOptions}
/>
</ErrorBoundary>
3. Set Appropriate Page Size
// Small datasets
<FormAsyncSingleSelect pageSize={10} />
// Large datasets
<FormAsyncSingleSelect pageSize={20} />
4. Handle Network Errors
const loadOptions = async (search: string, page: number) => {
try {
const response = await fetch(`/api/data?search=${search}&page=${page}`);
if (!response.ok) throw new Error('Network error');
return await response.json();
} catch (error) {
console.error('Load error:', error);
return []; // Return empty on error
}
};
Performance Optimization
1. Lazy Enable
Only enable fetching saat dropdown open:
enabled: open && !disabled // Built-in optimization
2. Cache Reset
Cache di-reset saat query berubah:
const fetchFirst = React.useCallback(() => {
cacheRef.current = new Map(); // Reset cache
setHasMore(true);
fetchPage(1, "replace");
}, [fetchPage]);
3. Deduplicate Options
Prevent duplicate options saat merge pagination:
function mergeUnique<T>(
current: T[],
incoming: T[],
getValue: (o: T) => string
): T[] {
const existing = new Set(current.map(getValue));
const added: T[] = [];
for (const item of incoming) {
const id = getValue(item);
if (!existing.has(id)) {
existing.add(id);
added.push(item);
}
}
return [...current, ...added];
}
Comparison Table
| Feature | AsyncSingleSelect | AsyncSingleSelectRHF |
|---|---|---|
| Form Integration | ❌ Manual | ✅ React Hook Form |
| Validation | ❌ Manual | ✅ Built-in RHF |
| Error Messages | ❌ Manual | ✅ Automatic |
| Type Safety | ✅ Good | ✅ Excellent (Generics) |
| Caching | ❌ No | ✅ Yes |
| IntersectionObserver | ❌ No | ✅ Yes |
| Code Complexity | Simple | Advanced |
| Bundle Size | Smaller | Larger (+RHF) |
Troubleshooting
Form Value Tidak Update
Problem: Selected value tidak tersimpan di form.
Solution: Pastikan name prop match dengan field di form schema:
// ✅ Correct
interface FormData {
userId: string;
}
<FormAsyncSingleSelect
name="userId" // Match dengan interface
control={control}
/>
Validation Tidak Jalan
Problem: Rules validation tidak trigger.
Solution: Pastikan rules di-pass dengan benar:
<FormAsyncSingleSelect
name="field"
control={control}
rules={{
required: "Field is required",
validate: (v) => !!v || "Please select"
}}
/>
Cache Tidak Clear
Problem: Data lama masih muncul setelah refetch.
Solution: Cache otomatis di-reset saat query berubah. Untuk manual reset, gunakan standalone AsyncSingleSelect dengan custom reset() handler.
TypeScript Error pada Generics
Problem: Type error saat menggunakan custom interface.
Solution: Explicitly specify types:
<FormAsyncSingleSelect<FormData, CustomOption>
name="field"
control={control}
loadOptions={loadOptions}
/>
Migration Guide
From Standalone to RHF
// Before (Standalone)
const [selected, setSelected] = useState(null);
<AsyncSingleSelect
selected={selected}
onChange={setSelected}
loadOptions={loadOptions}
/>
// After (RHF)
const { control } = useForm();
<FormAsyncSingleSelect
name="field"
control={control}
loadOptions={loadOptions}
/>
From Select to AsyncSelect
// Before (Static select)
<Controller
name="city"
control={control}
render={({ field }) => (
<Select
options={cities}
value={field.value}
onChange={field.onChange}
/>
)}
/>
// After (Async select)
<FormAsyncSingleSelect
name="city"
control={control}
loadOptions={async (search, page) => {
const res = await fetch(`/api/cities?search=${search}&page=${page}`);
return res.json();
}}
/>
Browser Compatibility
- ✅ Modern browsers (Chrome 90+, Firefox 88+, Safari 14+, Edge 90+)
- ✅ Requires IntersectionObserver API
- ✅ Requires ES6+ support
- ✅ Mobile responsive
Related Components
- AsyncSingleSelect - Standalone version (tanpa RHF)
- AsyncMultiSelectRHF - Multi-selection dengan RHF
- AsyncMultiSelect - Multi-selection standalone
Version History
- v1.0.0: Initial release dengan RHF integration
- v1.1.0: Added caching mechanism
- v1.2.0: Added IntersectionObserver for smooth scroll
- v1.3.0: Added TypeScript generics support
- v1.4.0: Added mapFormToOption/mapOptionToForm transformers
Contributing
Untuk improvement atau bug fixes:
- Test dengan multiple form scenarios
- Ensure backward compatibility dengan RHF versions
- Update TypeScript types jika ada perubahan interface
- Add unit tests untuk validation rules