{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-select-loader", "title": "useSelectLoader", "description": "Async items loader for Select: eager load-once or server-side filter modes, with AbortController, debounce, and a selected-item cache so chip labels survive across refetches.", "registryDependencies": [ "@easy-shadcn/use-select-items" ], "files": [ { "path": "registry/hooks/use-select-loader.ts", "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { SelectItem } from \"@/registry/hooks/use-select-items\";\n\nexport type SelectItemsLoader = (\n query: string,\n signal: AbortSignal\n) => Promise;\n\nexport interface UseSelectLoaderOptions {\n debounceMs?: number;\n loadItems?: SelectItemsLoader;\n loadOn?: \"mount\" | \"open\";\n open?: boolean;\n query?: string;\n serverSideFilter?: boolean;\n value?: string | string[] | null;\n}\n\nexport interface UseSelectLoaderReturn {\n enabled: boolean;\n error: unknown;\n items: SelectItem[];\n loading: boolean;\n refresh: () => void;\n}\n\nfunction toArray(value: string | string[] | null | undefined): string[] {\n if (value === null || value === undefined) {\n return [];\n }\n return Array.isArray(value) ? value : [value];\n}\n\nexport function useSelectLoader({\n loadItems,\n loadOn = \"mount\",\n serverSideFilter = false,\n debounceMs = 250,\n open,\n query = \"\",\n value,\n}: UseSelectLoaderOptions): UseSelectLoaderReturn {\n const enabled = Boolean(loadItems);\n const [items, setItems] = useState([]);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState(null);\n const [selectedCache, setSelectedCache] = useState>(\n () => new Map()\n );\n const abortRef = useRef(null);\n const debounceRef = useRef | null>(null);\n const hasLoadedRef = useRef(false);\n const loadItemsRef = useRef(loadItems);\n const queryRef = useRef(query);\n const serverSideFilterRef = useRef(serverSideFilter);\n useEffect(() => {\n loadItemsRef.current = loadItems;\n }, [loadItems]);\n useEffect(() => {\n queryRef.current = query;\n }, [query]);\n useEffect(() => {\n serverSideFilterRef.current = serverSideFilter;\n }, [serverSideFilter]);\n\n const fetchItems = useCallback((q: string) => {\n const fn = loadItemsRef.current;\n if (!fn) {\n return;\n }\n const controller = new AbortController();\n abortRef.current?.abort();\n abortRef.current = controller;\n setLoading(true);\n setError(null);\n setItems([]);\n fn(q, controller.signal)\n .then((result) => {\n if (controller.signal.aborted) {\n return;\n }\n setItems(result);\n hasLoadedRef.current = true;\n setLoading(false);\n })\n .catch((e: unknown) => {\n if (controller.signal.aborted) {\n return;\n }\n if (e instanceof DOMException && e.name === \"AbortError\") {\n return;\n }\n setError(e);\n setLoading(false);\n });\n }, []);\n\n const refresh = useCallback(() => {\n hasLoadedRef.current = false;\n const q = serverSideFilterRef.current ? queryRef.current : \"\";\n fetchItems(q);\n }, [fetchItems]);\n\n // Eager mode: fetch once. loadOn === \"mount\" => on mount; loadOn === \"open\" => on first open.\n useEffect(() => {\n if (!enabled || serverSideFilter) {\n return;\n }\n if (hasLoadedRef.current) {\n return;\n }\n if (loadOn === \"open\" && !open) {\n return;\n }\n fetchItems(\"\");\n }, [enabled, serverSideFilter, loadOn, open, fetchItems]);\n\n // Server-side filter mode: fetch on open + on every query change (debounced).\n useEffect(() => {\n if (!(enabled && serverSideFilter)) {\n return;\n }\n if (!open) {\n return;\n }\n if (debounceRef.current) {\n clearTimeout(debounceRef.current);\n }\n const wait = query === \"\" && !hasLoadedRef.current ? 0 : debounceMs;\n debounceRef.current = setTimeout(() => {\n fetchItems(query);\n }, wait);\n return () => {\n if (debounceRef.current) {\n clearTimeout(debounceRef.current);\n }\n };\n }, [enabled, serverSideFilter, open, query, debounceMs, fetchItems]);\n\n // When popup closes in server-side mode, reset hasLoadedRef so reopening refetches default list.\n useEffect(() => {\n if (!(enabled && serverSideFilter)) {\n return;\n }\n if (!open) {\n hasLoadedRef.current = false;\n }\n }, [enabled, serverSideFilter, open]);\n\n // Cleanup on unmount.\n useEffect(\n () => () => {\n abortRef.current?.abort();\n if (debounceRef.current) {\n clearTimeout(debounceRef.current);\n }\n },\n []\n );\n\n // Maintain a cache of selected items so chips/triggers can still display labels\n // when async results no longer contain previously-selected values.\n useEffect(() => {\n if (!enabled) {\n return;\n }\n const arr = toArray(value);\n if (arr.length === 0) {\n return;\n }\n const itemIndex = new Map();\n for (const item of items) {\n itemIndex.set(item.value, item);\n }\n setSelectedCache((prev) => {\n let changed = false;\n const next = new Map(prev);\n for (const v of arr) {\n const found = itemIndex.get(v);\n if (found && next.get(v) !== found) {\n next.set(v, found);\n changed = true;\n }\n }\n return changed ? next : prev;\n });\n }, [items, value, enabled]);\n\n const mergedItems = useMemo(() => {\n if (!enabled) {\n return [];\n }\n const arr = toArray(value);\n if (arr.length === 0) {\n return items;\n }\n const present = new Set();\n for (const item of items) {\n present.add(item.value);\n }\n const additions: SelectItem[] = [];\n for (const v of arr) {\n if (present.has(v)) {\n continue;\n }\n const cached = selectedCache.get(v);\n if (cached) {\n additions.push(cached);\n }\n }\n if (additions.length === 0) {\n return items;\n }\n return [...items, ...additions];\n }, [enabled, items, value, selectedCache]);\n\n return {\n enabled,\n error,\n items: mergedItems,\n loading,\n refresh,\n };\n}\n", "type": "registry:hook", "target": "hooks/use-select-loader.ts" } ], "type": "registry:hook" }