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