AsyncMultiSelectRHF
Komponen dropdown multi-select berbasis async dengan integrasi React Hook Form, mendukung lazy loading, pagination dengan caching, validation, chips display, dan non-removable items control.
Overview
AsyncMultiSelectRHF (React Hook Form version) adalah wrapper dari AsyncMultiSelect yang terintegrasi penuh dengan React Hook Form. Komponen ini cocok untuk form kompleks dengan multiple selection, validation rules, dan advanced features seperti fixed/non-removable items.
Features
- ✅ React Hook Form Integration: Full integration dengan RHF
- ✅ Multiple Selection with Chips: Display selected items sebagai badges
- ✅ Async Lazy Loading: Load data dari API on-demand
- ✅ Pagination with Caching: Intelligent caching untuk performance
- ✅ Debounce Search: Optimized search (400ms)
- ✅ Non-Removable Items: Control individual item removal
- ✅ Form Validation: Built-in RHF validation
- ✅ IntersectionObserver: Smooth infinite scroll
- ✅ TypeScript Generics: Full type safety
- ✅ Custom Chip Styling: Customizable chips appearance
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
FormAsyncMultiSelect 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 |
getOptionValue | (option: TOption) => string | o => o.value | Extract value |
className | string | - | Custom CSS class |
debounceMs | number | 400 | Debounce delay (ms) |
allowClear | boolean | true | Show "clear all" button |
maxHeight | number | 260 | Max dropdown height (px) |
chipClassName | string | - | Custom chip CSS class |
isValueRemovable | (opt: TOption) => boolean | - | Control per-item removal |
selectedItemClassName | string | "bg-primary/10" | Selected item style |
rules | RegisterOptions | - | RHF validation rules |
mapFormToOptions | (formValue: any) => TOption[] | - | Transform form value |
mapOptionsToForm | (options: TOption[]) => any | - | Transform to form value |
DefaultOption Interface
interface DefaultOption {
label: string;
value: string;
[extra: string]: any;
}
Usage
Basic Usage with React Hook Form
import { useForm } from "react-hook-form";
import { FormAsyncMultiSelect } from "@/components/ui/async-multi-select-rhf";
interface FormData {
tags: Array<{ label: string; value: string }>;
}
function ArticleForm() {
const { control, handleSubmit } = useForm<FormData>({
defaultValues: {
tags: []
}
});
const loadTags = async (search: string, page: number) => {
const response = await fetch(
`/api/tags?search=${search}&page=${page}&limit=10`
);
const data = await response.json();
return data.tags.map(tag => ({
label: tag.name,
value: tag.id
}));
};
const onSubmit = (data: FormData) => {
console.log('Selected tags:', data.tags);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormAsyncMultiSelect
name="tags"
control={control}
placeholder="Pilih Tags"
loadOptions={loadTags}
/>
<button type="submit">Submit</button>
</form>
);
}
With Validation Rules
<FormAsyncMultiSelect
name="skills"
control={control}
placeholder="Pilih Skills (min 2)"
loadOptions={loadSkills}
rules={{
required: "Minimal 1 skill harus dipilih",
validate: {
minLength: (value) =>
value.length >= 2 || "Minimal 2 skills diperlukan",
maxLength: (value) =>
value.length <= 5 || "Maksimal 5 skills"
}
}}
/>
{/* Display error */}
{errors.skills && (
<p className="text-sm text-red-500">
{errors.skills.message}
</p>
)}
With Non-Removable Items (Fixed Items)
interface User {
id: string;
name: string;
isCurrentUser?: boolean;
}
<FormAsyncMultiSelect<FormData, User>
name="assignees"
control={control}
placeholder="Pilih Assignees"
loadOptions={loadUsers}
getOptionLabel={(u) => u.name}
getOptionValue={(u) => u.id}
// Current user tidak bisa di-remove
isValueRemovable={(user) => !user.isCurrentUser}
/>
With Custom TypeScript Interface
interface Product {
id: string;
name: string;
price: number;
category: string;
}
interface FormData {
products: Product[];
}
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;
};
<FormAsyncMultiSelect<FormData, Product>
name="products"
control={control}
loadOptions={loadProducts}
getOptionLabel={(p) => `${p.name} - Rp ${p.price}`}
getOptionValue={(p) => p.id}
chipClassName="bg-green-100 text-green-800"
pageSize={15}
/>
With Default Values
const { control } = useForm<FormData>({
defaultValues: {
tags: [
{ label: "React", value: "1" },
{ label: "TypeScript", value: "2" }
]
}
});
<FormAsyncMultiSelect
name="tags"
control={control}
loadOptions={loadTags}
/>
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.pagination.hasNextPage
};
};
Advanced Examples
With Value Transformation
// Form menyimpan array of IDs, component butuh array of objects
interface FormData {
userIds: string[]; // Only store IDs
}
interface User {
id: string;
name: string;
}
<FormAsyncMultiSelect<FormData, User>
name="userIds"
control={control}
loadOptions={loadUsers}
getOptionLabel={(u) => u.name}
getOptionValue={(u) => u.id}
// Transform stored IDs to User objects
mapFormToOptions={(ids: string[]) => {
if (!ids || ids.length === 0) return [];
return users.filter(u => ids.includes(u.id));
}}
// Transform User objects back to IDs
mapOptionsToForm={(users: User[]) => users.map(u => u.id)}
/>
With Current User as Fixed Item
const currentUser = {
id: "current-user-id",
name: "You",
isCurrentUser: true
};
const { control } = useForm<FormData>({
defaultValues: {
assignees: [currentUser] // Pre-select current user
}
});
<FormAsyncMultiSelect
name="assignees"
control={control}
loadOptions={loadUsers}
// Prevent removal of current user
isValueRemovable={(user) => !user.isCurrentUser}
rules={{
validate: (value) =>
value.some(u => u.isCurrentUser) ||
"You must be assigned to this task"
}}
/>
With Custom Chip Styling
<FormAsyncMultiSelect
name="categories"
control={control}
loadOptions={loadCategories}
// Custom chip colors
chipClassName="bg-purple-100 text-purple-800 font-medium"
// Custom selected item highlight
selectedItemClassName="bg-purple-50 text-purple-900 font-semibold"
/>
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 (
<FormAsyncMultiSelect
name="tags"
control={control}
loadOptions={loadTags}
disabled={isSubmitting} // Disable saat submit
/>
);
}
How It Works
1. Caching Mechanism
Sama seperti single select RHF version, menggunakan Map-based cache:
const cacheRef = React.useRef<Map<string, TOption[]>>(new Map());
const cacheKey = `${query}:::${page}`;
if (cacheRef.current.has(cacheKey)) {
return cacheRef.current.get(cacheKey)!;
}
2. Non-Removable Items Control
const toggle = (opt: TOption) => {
const existing = selectedMap.has(id);
if (existing) {
// Check if removable
if (!isValueRemovable || isValueRemovable(opt)) {
onChange(value.filter((v) => getOptionValue(v) !== id));
}
// Else, do nothing (item tetap selected)
} else {
onChange([...value, opt]);
}
};
3. Chips Display with Remove Button
{value.map((opt) => {
const removable = !isValueRemovable || isValueRemovable(opt);
return (
<span className="chip">
{label}
{!disabled && removable && (
<button onClick={() => removeItem(opt)}>
<X className="w-3 h-3" />
</button>
)}
</span>
);
})}
4. Selected Items Set
Menggunakan Set untuk O(1) lookup:
const selectedMap = React.useMemo(
() => new Set(value.map((v) => getOptionValue(v))),
[value, getOptionValue]
);
const isSelected = selectedMap.has(optionId);
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)}>
<FormAsyncMultiSelect
name="tags"
control={methods.control}
loadOptions={loadTags}
/>
</form>
</FormProvider>
);
}
With Controller (Manual)
import { Controller, useForm } from "react-hook-form";
import { AsyncMultiSelect } from "./async-multi-select-rhf";
function ManualForm() {
const { control } = useForm();
return (
<Controller
name="tags"
control={control}
render={({ field }) => (
<AsyncMultiSelect
value={field.value}
onChange={field.onChange}
loadOptions={loadOptions}
/>
)}
/>
);
}
Best Practices
1. Use Memoized loadOptions
const loadOptions = useCallback(
async (search: string, page: number) => {
// ... fetch logic
},
[]
);
2. Validate Selection Count
<FormAsyncMultiSelect
name="skills"
control={control}
loadOptions={loadSkills}
rules={{
validate: {
min: (v) => v.length >= 1 || "Select at least 1",
max: (v) => v.length <= 5 || "Maximum 5 selections"
}
}}
/>
3. Handle Current User in Teams
// Always include current user and make non-removable
const defaultValues = {
members: [currentUser]
};
<FormAsyncMultiSelect
name="members"
control={control}
loadOptions={loadUsers}
isValueRemovable={(u) => u.id !== currentUser.id}
/>
4. Clear Button for Optional Fields
<FormAsyncMultiSelect
name="optionalTags"
control={control}
loadOptions={loadTags}
allowClear={true} // Show clear all button
/>
Performance Optimization
1. Lazy Enable
Fetching hanya aktif saat dropdown open:
enabled: open && !disabled
2. Deduplicate on Merge
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];
}
3. Memoized Selected Map
const selectedMap = React.useMemo(
() => new Set(value.map((v) => getOptionValue(v))),
[value, getOptionValue]
);
Comparison Table
| Feature | AsyncMultiSelect | AsyncMultiSelectRHF |
|---|---|---|
| Form Integration | ❌ Manual | ✅ React Hook Form |
| Validation | ❌ Manual | ✅ Built-in RHF |
| Non-Removable Items | ❌ No | ✅ Yes (isValueRemovable) |
| Caching | ❌ No | ✅ Yes |
| IntersectionObserver | ❌ No | ✅ Yes |
| Custom Chip Style | ❌ Fixed | ✅ Customizable |
| Type Safety | ✅ Good | ✅ Excellent (Generics) |
| Bundle Size | Smaller | Larger (+RHF) |
Troubleshooting
Items Tidak Bisa Di-remove
Problem: Click X pada chip tidak remove item.
Solution: Check isValueRemovable function:
// ✅ Correct - return true to allow removal
isValueRemovable={(item) => !item.isFixed}
// ❌ Wrong - return false blocks removal
isValueRemovable={(item) => item.isFixed}
Fixed Items Masih Bisa Di-remove
Problem: Fixed items masih bisa dihapus.
Solution: Pastikan isValueRemovable return false untuk fixed items:
isValueRemovable={(user) => user.id !== currentUserId}
Form Value Tidak Update
Problem: Selected values tidak tersimpan di form.
Solution: Pastikan name prop match dengan form schema:
interface FormData {
tags: Array<{ label: string; value: string }>;
}
<FormAsyncMultiSelect
name="tags" // Must match interface
control={control}
/>
TypeScript Error pada Generics
Problem: Type error saat custom interface.
Solution: Explicitly specify both generic types:
<FormAsyncMultiSelect<FormData, CustomOption>
name="field"
control={control}
loadOptions={loadOptions}
/>
Migration Guide
From Standalone to RHF
// Before (Standalone)
const [selected, setSelected] = useState<Option[]>([]);
<AsyncMultiSelect
selected={selected}
onChange={setSelected}
loadOptions={loadOptions}
/>
// After (RHF)
const { control } = useForm();
<FormAsyncMultiSelect
name="items"
control={control}
loadOptions={loadOptions}
/>
From Single to Multi Select
// Before (Single)
<FormAsyncSingleSelect
name="category"
control={control}
loadOptions={loadCategories}
/>
// After (Multi)
<FormAsyncMultiSelect
name="categories" // Plural
control={control}
loadOptions={loadCategories}
/>
Real-World Use Cases
1. Task Assignees with Current User
const currentUser = getCurrentUser();
<FormAsyncMultiSelect
name="assignees"
control={control}
loadOptions={loadTeamMembers}
isValueRemovable={(user) => user.id !== currentUser.id}
rules={{
validate: (users) =>
users.some(u => u.id === currentUser.id) ||
"You must assign yourself"
}}
/>
2. Article Tags (Max 5)
<FormAsyncMultiSelect
name="tags"
control={control}
loadOptions={loadTags}
placeholder="Add tags (max 5)"
rules={{
validate: (tags) =>
tags.length <= 5 || "Maximum 5 tags allowed"
}}
/>
3. Product Categories (At Least 1)
<FormAsyncMultiSelect
name="categories"
control={control}
loadOptions={loadCategories}
allowClear={false}
rules={{
required: "Select at least 1 category",
validate: (cats) =>
cats.length > 0 || "Cannot be empty"
}}
/>
Browser Compatibility
- ✅ Modern browsers (Chrome 90+, Firefox 88+, Safari 14+, Edge 90+)
- ✅ Requires IntersectionObserver API
- ✅ Requires ES6+ support
- ✅ Mobile responsive with touch support
Related Components
- AsyncMultiSelect - Standalone version (tanpa RHF)
- AsyncSingleSelectRHF - Single selection dengan RHF
- AsyncSingleSelect - Single selection standalone
Version History
- v1.0.0: Initial release dengan RHF integration
- v1.1.0: Added caching mechanism
- v1.2.0: Added
isValueRemovablefor non-removable items - v1.3.0: Added custom chip styling
- v1.4.0: Added
mapFormToOptions/mapOptionsToFormtransformers - v1.5.0: Added IntersectionObserver for smooth scroll
Contributing
Untuk improvement:
- Test dengan complex validation scenarios
- Ensure backward compatibility dengan RHF versions
- Update TypeScript types jika ada perubahan
- Add unit tests untuk
isValueRemovablelogic - Document edge cases untuk fixed items