Skip to main content

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

PropTypeDefaultDescription
namePath<TFieldValues>-Required. Field name di form
controlControl<TFieldValues>-Required. RHF control object
loadOptions(query: string, page: number) => Promise<TOption[]>-Required. Function fetch data
placeholderstring"Pilih..."Placeholder text
noDataLabelstring"Tidak ditemukan"Empty state text
searchingLabelstring"Mencari..."Searching state text
loadingLabelstring"Memuat..."Loading state text
disabledbooleanfalseDisable select
pageSizenumber10Items per page
getOptionLabel(option: TOption) => stringo => o.labelExtract label dari option
getOptionValue(option: TOption) => stringo => o.valueExtract value dari option
classNamestring-Custom CSS class
debounceMsnumber400Debounce delay (ms)
allowClearbooleantrueShow clear button
maxHeightnumber260Max height dropdown (px)
rulesRegisterOptions-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);

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

FeatureAsyncSingleSelectAsyncSingleSelectRHF
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 ComplexitySimpleAdvanced
Bundle SizeSmallerLarger (+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

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:

  1. Test dengan multiple form scenarios
  2. Ensure backward compatibility dengan RHF versions
  3. Update TypeScript types jika ada perubahan interface
  4. Add unit tests untuk validation rules