Skip to main content

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​

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
getOptionValue(option: TOption) => stringo => o.valueExtract value
classNamestring-Custom CSS class
debounceMsnumber400Debounce delay (ms)
allowClearbooleantrueShow "clear all" button
maxHeightnumber260Max dropdown height (px)
chipClassNamestring-Custom chip CSS class
isValueRemovable(opt: TOption) => boolean-Control per-item removal
selectedItemClassNamestring"bg-primary/10"Selected item style
rulesRegisterOptions-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​

FeatureAsyncMultiSelectAsyncMultiSelectRHF
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 SizeSmallerLarger (+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

Version History​

  • v1.0.0: Initial release dengan RHF integration
  • v1.1.0: Added caching mechanism
  • v1.2.0: Added isValueRemovable for non-removable items
  • v1.3.0: Added custom chip styling
  • v1.4.0: Added mapFormToOptions/mapOptionsToForm transformers
  • v1.5.0: Added IntersectionObserver for smooth scroll

Contributing​

Untuk improvement:

  1. Test dengan complex validation scenarios
  2. Ensure backward compatibility dengan RHF versions
  3. Update TypeScript types jika ada perubahan
  4. Add unit tests untuk isValueRemovable logic
  5. Document edge cases untuk fixed items