JavaScript & React Development Expert

Generates production-grade, typed React and JavaScript code with tests, accessibility, and clear architectural reasoning.

// prompt
You are a **senior front-end engineer** specializing in modern JavaScript, TypeScript, and React. You write production-grade, maintainable code and explain the trade-offs behind your decisions. ## Build Request - **What to build:** {{feature_or_component_description}} - **Project type:** {{app_reusable_component_hook_utility}} - **Key requirements:** {{functional_requirements_and_edge_cases}} - **Tech stack:** React {{react_version}}, TypeScript {{strict_or_loose}}, styling via {{styling_approach}}, tests via {{testing_library}} - **Constraints:** {{performance_bundle_browser_support_constraints}} ## How to Respond 1. **Clarify silently, then plan.** State 2-4 assumptions you are making and a one-paragraph approach before any code. 2. **Model the types first.** Define explicit `interface`/`type` declarations for props, state, and API shapes. Avoid `any`. 3. **Implement cleanly.** Use modern ES2022+ syntax and idiomatic hooks. Keep components small and composable; extract custom hooks for non-trivial logic. 4. **Handle reality.** Cover loading, empty, and error states. Add input validation and meaningful error handling instead of silent failures. 5. **Make it accessible and responsive.** Use semantic HTML, ARIA only where needed, keyboard support, and a mobile-first layout. 6. **Prove it works.** Provide focused unit tests with {{testing_library}} covering the main path and at least one edge case. ## Deliverables - A short **architecture note** explaining structure and key decisions. - **Complete, runnable code** in labeled fenced blocks (`tsx`/`ts`), with imports and exports. - Inline comments only where intent is non-obvious. - A **test file** for the primary unit. - A brief **"Next steps / trade-offs"** list (e.g., what to optimize, alternatives considered). Optimize for readability, type safety, and reusability over cleverness. If a requirement is ambiguous, choose a sensible default and flag it rather than guessing silently.
Fill in the variables

Example run

Generated by Claude Opus 5.5 on September 24, 2026

Inputs used

Feature or component description
Build a reusable `AsyncMultiSelect` combobox component for a project management SaaS app (internal codename "Taskwell"). It lets users search for and select multiple team members to assign to a task. As the user types, it queries a `/api/users/search?q=` endpoint (debounced) and shows matching results in a dropdown listbox. Selected users appear as removable "chip" tags above or inside the input field. It should support pre-populating with already-assigned users (e.g., editing an existing task) and expose the current selection via a controlled `value`/`onChange` API so the parent form (built with react-hook-form) can manage state.
Choose one
Reusable Component
Functional requirements and edge cases
- Controlled component: accepts `value: User[]` and `onChange: (users: User[]) => void`; no internal source of truth for selection. - Debounce search input by 300ms; cancel in-flight requests when a new query fires (use AbortController) to avoid race conditions where an older response overwrites a newer one. - Minimum 2 characters before triggering a search; show a hint below the input ("Type at least 2 characters") when below threshold. - Loading state: show a spinner inside the dropdown while fetching, without closing the currently visible results. - Empty state: "No users found for \"{query}\"" when the API returns zero results. - Error state: if the fetch fails (network error or non-2xx), show an inline error message with a "Retry" button; never fail silently. - Selected users must be excluded from the dropdown results (no duplicate selection). - Max selection limit of 10 users; once reached, disable further selection and show a small note "Maximum 10 assignees". - Removable chips: each chip has an "x" button (and Backspace key when input is empty and focused removes the last chip). - Full keyboard support: ArrowUp/ArrowDown to move highlighted option, Enter to select highlighted option, Escape to close dropdown, Tab moves focus away and closes dropdown. - Click-outside closes the dropdown. - API response shape: `{ id: string; name: string; email: string; avatarUrl?: string }[]`; component must validate/guard against malformed responses (missing fields) rather than crashing. - Must work when `value` prop is pre-populated on mount (edit mode) without triggering an initial fetch.
React version
18.3
Strict or loose
strict
Styling approach
Tailwind CSS v3
Testing library
React Testing Library + Vitest
Performance / bundle / browser support constraints
Component (plus its hook) should add no more than ~4KB gzipped beyond React itself — no heavy combobox libraries (e.g., avoid downshift/react-select), implement the listbox/combobox behavior with plain hooks and ARIA attributes. Must support the last 2 versions of Chrome, Firefox, Safari, and Edge; no IE11 support needed. Avoid layout thrashing on rapid typing — debounce and memoize filtered/rendered option lists. Must be tree-shakeable and have zero runtime CSS-in-JS cost (Tailwind utility classes only, no styled-components). Avatar images should lazy-load (`loading="lazy"`) and fall back to initials when `avatarUrl` is missing or fails to load.

Assumptions

  1. The fetcher is injectable. The default implementation calls GET /api/users/search?q=. A fetchUsers(query, signal) prop overrides it, which keeps the component reusable and makes tests independent of fetch.
  2. The input clears after a selection. This follows the common chip-input pattern. When the input clears, the search resets and the "Type at least 2 characters" hint returns. Flagged: if product wants the query kept (e.g. to add several "Smith"s), remove one line in selectUser.
  3. Malformed items are dropped, malformed payloads are errors. Individual items missing id, name or email are filtered out (with a dev-only warning). A non-array payload means the API contract broke, so it becomes an error state with Retry.
  4. Tab closes the dropdown, so Enter retries. Because Tab moves focus away, the Retry button is only reachable by pointer. To keep retry keyboard-accessible, Enter retries while the error is showing. This is announced via screen-reader text.

Approach: The selection lives only in the parent (value/onChange). The component owns only UI state: the query text, whether the dropdown is open, and the highlighted option. A useUserSearch hook handles the network work: it debounces the trimmed query, runs one request per settled query with an AbortController, and ignores any response whose signal was aborted. That prevents stale responses even if a custom fetcher ignores the signal. Search is keyed only on the query, so mounting with a pre-populated value never fires a request. Options are computed with useMemo (results minus selected IDs), and rows are memo'd so typing doesn't re-render the list. ARIA follows the WAI-ARIA 1.2 combobox pattern: role="combobox", aria-activedescendant, and a single persistent live region for announcements.


Architecture note

src/components/AsyncMultiSelect/
├── types.ts               # User, FetchUsers, SearchState, props
├── parseUsers.ts          # runtime validation of untrusted API data
├── fetchUsers.ts          # default fetcher (non-2xx → throw)
├── useDebouncedValue.ts   # generic debounce hook
├── useUserSearch.ts       # debounce + abort + reducer state machine
├── Avatar.tsx             # lazy image + initials fallback
├── UserChip.tsx           # removable selected-user tag
├── UserOption.tsx         # memoized listbox row
├── AsyncMultiSelect.tsx   # composition, keyboard, ARIA
├── AsyncMultiSelect.test.tsx
└── index.ts               # named exports only (tree-shakeable)

Key decisions:

  • Highlight is stored as an ID, not an index. Results change while you type. If the highlighted user disappears, the highlight falls back to the first option, and no "reset index" effects are needed.
  • The search state is a reducer with four states: idle | loading | success | error. During loading the previous results stay in place, so the dropdown doesn't collapse or flicker. A derived isSearching flag also covers the debounce window, so "No users found" never flashes for a half-typed query.
  • Click-outside uses pointerdown with composedPath(). This covers mouse and touch, and works inside shadow roots.
  • There are no dependencies beyond React. The estimated size is about 3KB min+gz; verify it with size-limit in CI.

Code

types.ts

export interface User {
  readonly id: string;
  readonly name: string;
  readonly email: string;
  readonly avatarUrl?: string;
}

/** Must honor `signal` where possible; the hook also ignores aborted results defensively. */
export type FetchUsers = (query: string, signal: AbortSignal) => Promise<unknown>;

export type SearchStatus = 'idle' | 'loading' | 'success' | 'error';

export interface SearchState {
  status: SearchStatus;
  results: readonly User[];
  /** The query that produced `results` / `error` (not necessarily the live input). */
  query: string;
  error: string | null;
}

export interface AsyncMultiSelectProps {
  /** Controlled selection — the single source of truth. */
  value: readonly User[];
  onChange: (users: User[]) => void;
  label: string;
  id?: string;
  placeholder?: string;
  /** @default 10 */
  maxSelected?: number;
  /** @default 2 */
  minQueryLength?: number;
  /** @default 300 */
  debounceMs?: number;
  /** Defaults to GET /api/users/search?q= */
  fetchUsers?: FetchUsers;
  disabled?: boolean;
  /** Forwarded for react-hook-form "touched" tracking. */
  onBlur?: () => void;
  /** Validation message from the parent form (e.g. RHF fieldState.error). */
  errorMessage?: string;
  className?: string;
}

parseUsers.ts

import type { User } from './types';

export class UserSearchResponseError extends Error {
  override name = 'UserSearchResponseError';
}

const isNonEmptyString = (v: unknown): v is string =>
  typeof v === 'string' && v.trim().length > 0;

function toUser(raw: unknown): User | null {
  if (typeof raw !== 'object' || raw === null) return null;
  const r = raw as Record<string, unknown>;
  if (!isNonEmptyString(r.id) || !isNonEmptyString(r.name) || !isNonEmptyString(r.email)) {
    return null;
  }
  return {
    id: r.id,
    name: r.name,
    email: r.email,
    // Spread keeps `avatarUrl` absent (not `undefined`) — friendly to exactOptionalPropertyTypes.
    ...(isNonEmptyString(r.avatarUrl) ? { avatarUrl: r.avatarUrl } : {}),
  };
}

/**
 * Validates untrusted API data. A non-array payload is a contract violation and throws;
 * individual malformed or duplicate items are dropped so one bad record can't break the list.
 */
export function parseUsers(data: unknown): User[] {
  if (!Array.isArray(data)) {
    throw new UserSearchResponseError('Unexpected response from user search.');
  }
  const seen = new Set<string>();
  const users: User[] = [];
  let dropped = 0;

  for (const item of data) {
    const user = toUser(item);
    if (!user || seen.has(user.id)) {
      dropped++;
      continue;
    }
    seen.add(user.id);
    users.push(user);
  }

  if (dropped > 0 && import.meta.env?.DEV) {
    console.warn(`[AsyncMultiSelect] Dropped ${dropped} malformed/duplicate user record(s).`);
  }
  return users;
}

fetchUsers.ts

import type { FetchUsers } from './types';

export const defaultFetchUsers: FetchUsers = async (query, signal) => {
  const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`, {
    signal,
    headers: { Accept: 'application/json' },
  });
  if (!res.ok) throw new Error(`User search failed (HTTP ${res.status}).`);
  return res.json() as Promise<unknown>;
};

useDebouncedValue.ts

import { useEffect, useState } from 'react';

export function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    if (delayMs <= 0) {
      setDebounced(value);
      return;
    }
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

useUserSearch.ts

import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { parseUsers, UserSearchResponseError } from './parseUsers';
import type { FetchUsers, SearchState, User } from './types';
import { useDebouncedValue } from './useDebouncedValue';

type SearchAction =
  | { type: 'reset' }
  | { type: 'start'; query: string }
  | { type: 'success'; query: string; results: User[] }
  | { type: 'failure'; query: string; error: string };

const initialState: SearchState = { status: 'idle', results: [], query: '', error: null };

function reducer(state: SearchState, action: SearchAction): SearchState {
  switch (action.type) {
    case 'reset':
      // Return the same reference when already idle so React bails out of the re-render.
      return state.status === 'idle' && state.results.length === 0 ? state : initialState;
    case 'start':
      // Keep previous results visible while the new request is in flight.
      return { ...state, status: 'loading', query: action.query, error: null };
    case 'success':
      return { status: 'success', results: action.results, query: action.query, error: null };
    case 'failure':
      return { status: 'error', results: [], query: action.query, error: action.error };
  }
}

function toErrorMessage(err: unknown): string {
  if (err instanceof UserSearchResponseError) return err.message;
  // fetch() rejects with TypeError on network failure / CORS.
  if (err instanceof TypeError) return 'Network error — check your connection and try again.';
  if (err instanceof Error && err.message) return err.message;
  return 'Something went wrong while searching.';
}

export interface UseUserSearchOptions {
  minLength: number;
  debounceMs: number;
  fetchUsers: FetchUsers;
  enabled: boolean;
}

export interface UseUserSearchResult extends SearchState {
  /** The settled (debounced, trimmed) query currently driving the search. */
  debouncedQuery: string;
  retry: () => void;
}

export function useUserSearch(
  rawQuery: string,
  { minLength, debounceMs, fetchUsers, enabled }: UseUserSearchOptions,
): UseUserSearchResult {
  const debouncedQuery = useDebouncedValue(rawQuery.trim(), debounceMs);
  const [state, dispatch] = useReducer(reducer, initialState);
  const [attempt, setAttempt] = useState(0);

  // Latest-ref so an inline `fetchUsers` prop doesn't re-trigger the search on every render.
  const fetchRef = useRef(fetchUsers);
  useEffect(() => {
    fetchRef.current = fetchUsers;
  }, [fetchUsers]);

  useEffect(() => {
    if (!enabled || debouncedQuery.length < minLength) {
      dispatch({ type: 'reset' });
      return;
    }

    const controller = new AbortController();
    const { signal } = controller;
    dispatch({ type: 'start', query: debouncedQuery });

    // Promise.resolve().then(...) also captures synchronous throws from a custom fetcher.
    Promise.resolve()
      .then(() => fetchRef.current(debouncedQuery, signal))
      .then(parseUsers)
      .then((results) => {
        if (!signal.aborted) dispatch({ type: 'success', query: debouncedQuery, results });
      })
      .catch((err: unknown) => {
        // Superseded requests are expected; everything else surfaces as an error state.
        if (signal.aborted) return;
        dispatch({ type: 'failure', query: debouncedQuery, error: toErrorMessage(err) });
      });

    return () => controller.abort();
  }, [debouncedQuery, minLength, enabled, attempt]);

  const retry = useCallback(() => setAttempt((n) => n + 1), []);

  return { ...state, debouncedQuery, retry };
}

Avatar.tsx

import { memo, useState } from 'react';

const firstChar = (word: string | undefined): string =>
  word ? (Array.from(word)[0] ?? '') : ''; // Array.from handles surrogate pairs / emoji

export function getInitials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  const initials = firstChar(parts[0]) + (parts.length > 1 ? firstChar(parts[parts.length - 1]) : '');
  return initials.toUpperCase() || '?';
}

interface AvatarProps {
  name: string;
  src?: string;
  size?: 'sm' | 'md';
}

const SIZES = {
  sm: { cls: 'h-5 w-5 text-[10px]', px: 20 },
  md: { cls: 'h-8 w-8 text-xs', px: 32 },
} as const;

export const Avatar = memo(function Avatar({ name, src, size = 'md' }: AvatarProps) {
  // Track *which* src failed so a new src automatically gets a fresh attempt.
  const [failedSrc, setFailedSrc] = useState<string | null>(null);
  const { cls, px } = SIZES[size];

  if (src && failedSrc !== src) {
    return (
      <img
        src={src}
        alt=""
        aria-hidden="true"
        loading="lazy"
        decoding="async"
        width={px}
        height={px} // explicit dimensions prevent layout shift while loading
        onError={() => setFailedSrc(src)}
        className={`${cls} shrink-0 rounded-full bg-slate-100 object-cover`}
      />
    );
  }

  return (
    <span
      aria-hidden="true"
      className={`${cls} inline-flex shrink-0 select-none items-center justify-center rounded-full bg-indigo-100 font-semibold text-indigo-700`}
    >
      {getInitials(name)}
    </span>
  );
});

UserChip.tsx

import { memo } from 'react';
import { Avatar } from './Avatar';
import type { User } from './types';

interface UserChipProps {
  user: User;
  onRemove: (userId: string) => void;
  disabled?: boolean;
}

export const UserChip = memo(function UserChip({ user, onRemove, disabled = false }: UserChipProps) {
  return (
    <li className="inline-flex max-w-full items-center gap-1 rounded-full bg-slate-100 py-0.5 pl-0.5 pr-1 text-sm text-slate-800">
      <Avatar name={user.name} src={user.avatarUrl} size="sm" />
      <span className="truncate" title={user.email}>
        {user.name}
      </span>
      <button
        type="button"
        onClick={() => onRemove(user.id)}
        disabled={disabled}
        aria-label={`Remove ${user.name}`}
        // Larger touch target on mobile, compact on desktop.
        className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-200 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none sm:h-5 sm:w-5"
      >
        <svg aria-hidden="true" viewBox="0 0 16 16" className="h-3 w-3">
          <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
        </svg>
      </button>
    </li>
  );
});

UserOption.tsx

import { memo } from 'react';
import { Avatar } from './Avatar';
import type { User } from './types';

interface UserOptionProps {
  user: User;
  domId: string;
  highlighted: boolean;
  onSelect: (user: User) => void;
  onHighlight: (userId: string) => void;
}

export const UserOption = memo(function UserOption({
  user,
  domId,
  highlighted,
  onSelect,
  onHighlight,
}: UserOptionProps) {
  return (
    <li
      id={domId}
      role="option"
      // Selected users are excluded from the list, so aria-selected marks the active option
      // (APG single-select semantics).
      aria-selected={highlighted}
      // Prevent the input from blurring so focus and aria-activedescendant stay intact.
      onMouseDown={(e) => e.preventDefault()}
      onClick={() => onSelect(user)}
      onMouseMove={() => {
        if (!highlighted) onHighlight(user.id);
      }}
      className={`flex cursor-pointer items-center gap-3 px-3 py-2.5 sm:py-2 ${
        highlighted ? 'bg-indigo-50 text-indigo-900' : 'text-slate-800'
      }`}
    >
      <Avatar name={user.name} src={user.avatarUrl} />
      <span className="min-w-0 flex-1">
        <span className="block truncate text-sm font-medium">{user.name}</span>
        <span className="block truncate text-xs text-slate-500">{user.email}</span>
      </span>
    </li>
  );
});

AsyncMultiSelect.tsx

import {
  forwardRef,
  useCallback,
  useEffect,
  useId,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type MouseEvent,
} from 'react';
import { defaultFetchUsers } from './fetchUsers';
import type { AsyncMultiSelectProps, User } from './types';
import { UserChip } from './UserChip';
import { UserOption } from './UserOption';
import { useUserSearch } from './useUserSearch';

function Spinner() {
  return (
    <svg className="h-4 w-4 text-indigo-500 motion-safe:animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
    </svg>
  );
}

export const AsyncMultiSelect = forwardRef<HTMLInputElement, AsyncMultiSelectProps>(
  function AsyncMultiSelect(
    {
      value,
      onChange,
      label,
      id,
      placeholder = 'Search team members…',
      maxSelected = 10,
      minQueryLength = 2,
      debounceMs = 300,
      fetchUsers = defaultFetchUsers,
      disabled = false,
      onBlur,
      errorMessage,
      className = '',
    },
    ref,
  ) {
    const reactId = useId();
    const inputId = id ?? `${reactId}-input`;
    const listboxId = `${reactId}-listbox`;
    const hintId = `${reactId}-hint`;
    const maxNoteId = `${reactId}-max`;
    const fieldErrorId = `${reactId}-error`;

    const containerRef = useRef<HTMLDivElement>(null);
    const inputRef = useRef<HTMLInputElement>(null);
    // Expose the input to react-hook-form so it can focus the field on validation errors.
    useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);

    const [inputValue, setInputValue] = useState('');
    const [isOpen, setIsOpen] = useState(false);
    const [highlightedId, setHighlightedId] = useState<string | null>(null);

    const atMax = value.length >= maxSelected;
    const trimmed = inputValue.trim();
    const belowMin = trimmed.length < minQueryLength;

    const search = useUserSearch(inputValue, {
      minLength: minQueryLength,
      debounceMs,
      fetchUsers,
      enabled: !atMax && !disabled,
    });

    const selectedIds = useMemo(() => new Set(value.map((u) => u.id)), [value]);
    const options = useMemo(
      () => search.results.filter((u) => !selectedIds.has(u.id)),
      [search.results, selectedIds],
    );

    // Falls back to the first option if the highlighted one disappears from the results.
    const highlightedIndex = useMemo(() => {
      if (options.length === 0) return -1;
      const idx = options.findIndex((o) => o.id === highlightedId);
      return idx === -1 ? 0 : idx;
    }, [options, highlightedId]);
    const highlightedOption: User | undefined = options[highlightedIndex];

    // "Searching" covers the debounce window too, so stale empty/error states never flash.
    const isSearching = search.status === 'loading' || trimmed !== search.debouncedQuery;
    const showPopup = isOpen && !disabled && !atMax && !belowMin;
    const showHint = isOpen && !disabled && !atMax && belowMin;
    const showError = showPopup && !isSearching && search.status === 'error';
    const showEmpty = showPopup && !isSearching && search.status === 'success' && options.length === 0;
    const emptyMessage =
      search.results.length === 0
        ? `No users found for "${search.query}"`
        : 'All matching users are already assigned';

    const announcement = !showPopup
      ? ''
      : isSearching
        ? 'Searching for users…'
        : showError
          ? '' // announced by role="alert"
          : showEmpty
            ? emptyMessage
            : `${options.length} ${options.length === 1 ? 'result' : 'results'} available.`;

    const optionDomId = useCallback(
      (userId: string) => `${listboxId}-opt-${encodeURIComponent(userId)}`,
      [listboxId],
    );

    const selectUser = useCallback(
      (user: User) => {
        if (value.length >= maxSelected || value.some((v) => v.id === user.id)) return;
        onChange([...value, user]);
        setInputValue(''); // flagged assumption: clear the query after each pick
        setHighlightedId(null);
        inputRef.current?.focus();
      },
      [value, maxSelected, onChange],
    );

    const removeUser = useCallback(
      (userId: string) => {
        onChange(value.filter((u) => u.id !== userId));
        inputRef.current?.focus(); // don't strand keyboard focus on a removed button
      },
      [value, onChange],
    );

    const handleRetry = useCallback(() => {
      search.retry();
      inputRef.current?.focus();
    }, [search.retry]); // eslint-disable-line react-hooks/exhaustive-deps -- retry is stable

    // Click/touch outside closes the dropdown.
    useEffect(() => {
      if (!isOpen) return;
      const handlePointerDown = (e: Event) => {
        const container = containerRef.current;
        if (container && !e.composedPath().includes(container)) setIsOpen(false);
      };
      document.addEventListener('pointerdown', handlePointerDown);
      return () => document.removeEventListener('pointerdown', handlePointerDown);
    }, [isOpen]);

    // Keep the keyboard-highlighted option visible in the scrollable list.
    const highlightedDomId = showPopup && highlightedOption ? optionDomId(highlightedOption.id) : undefined;
    useEffect(() => {
      if (!highlightedDomId) return;
      document.getElementById(highlightedDomId)?.scrollIntoView?.({ block: 'nearest' });
    }, [highlightedDomId]);

    const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
      switch (e.key) {
        case 'ArrowDown':
        case 'ArrowUp': {
          e.preventDefault();
          if (!isOpen) {
            setIsOpen(true);
            return;
          }
          if (options.length === 0) return;
          const delta = e.key === 'ArrowDown' ? 1 : -1;
          const next = (highlightedIndex + delta + options.length) % options.length;
          setHighlightedId(options[next]?.id ?? null);
          return;
        }
        case 'Enter': {
          if (e.nativeEvent.isComposing) return; // don't hijack IME confirmation
          if (showPopup && highlightedOption) {
            e.preventDefault(); // don't submit the parent form
            selectUser(highlightedOption);
          } else if (showError) {
            e.preventDefault();
            handleRetry();
          }
          return;
        }
        case 'Escape':
          if (isOpen) {
            e.preventDefault();
            e.stopPropagation(); // don't also close a surrounding modal
            setIsOpen(false);
          } else if (inputValue) {
            setInputValue('');
          }
          return;
        case 'Tab':
          setIsOpen(false);
          return;
        case 'Backspace':
          if (inputValue === '' && value.length > 0) {
            e.preventDefault();
            onChange(value.slice(0, -1));
          }
          return;
      }
    };

    // Clicking empty space in the field focuses the input (chips and buttons handle their own clicks).
    const handleFieldMouseDown = (e: MouseEvent<HTMLDivElement>) => {
      if (disabled || e.target !== e.currentTarget) return;
      e.preventDefault();
      inputRef.current?.focus();
      setIsOpen(true);
    };

    const optionNodes = useMemo(
      () =>
        options.map((user, i) => (
          <UserOption
            key={user.id}
            user={user}
            domId={optionDomId(user.id)}
            highlighted={i === highlightedIndex}
            onSelect={selectUser}
            onHighlight={setHighlightedId}
          />
        )),
      [options, highlightedIndex, optionDomId, selectUser],
    );

    const describedBy =
      [showHint && hintId, atMax && maxNoteId, errorMessage && fieldErrorId].filter(Boolean).join(' ') ||
      undefined;

    return (
      <div ref={containerRef} className={`relative w-full ${className}`}>
        <label htmlFor={inputId} className="mb-1 block text-sm font-medium text-slate-700">
          {label}
        </label>

        <div
          onMouseDown={handleFieldMouseDown}
          className={`flex min-h-[2.75rem] w-full flex-wrap items-center gap-1.5 rounded-lg border bg-white px-2 py-1.5 shadow-sm transition-colors focus-within:ring-2 ${
            errorMessage
              ? 'border-red-400 focus-within:ring-red-500/20'
              : 'border-slate-300 focus-within:border-indigo-500 focus-within:ring-indigo-500/20'
          } ${disabled ? 'cursor-not-allowed bg-slate-50 opacity-60' : 'cursor-text'}`}
        >
          {value.length > 0 && (
            <ul aria-label={`Selected ${label.toLowerCase()}`} className="flex max-w-full flex-wrap gap-1.5">
              {value.map((user) => (
                <UserChip key={user.id} user={user} onRemove={removeUser} disabled={disabled} />
              ))}
            </ul>
          )}

          <input
            ref={inputRef}
            id={inputId}
            type="text"
            role="combobox"
            autoComplete="off"
            autoCapitalize="none"
            spellCheck={false}
            aria-autocomplete="list"
            aria-expanded={showPopup}
            aria-controls={showPopup ? listboxId : undefined}
            aria-activedescendant={highlightedDomId}
            aria-describedby={describedBy}
            aria-invalid={errorMessage ? true : undefined}
            value={inputValue}
            onChange={(e) => {
              setInputValue(e.target.value);
              setHighlightedId(null);
              setIsOpen(true);
            }}
            onKeyDown={handleKeyDown}
            onFocus={() => setIsOpen(true)}
            onClick={() => setIsOpen(true)}
            onBlur={onBlur}
            disabled={disabled}
            // readOnly (not disabled) at the limit keeps focus + Backspace-to-remove working.
            readOnly={atMax}
            placeholder={atMax ? `Maximum ${maxSelected} reached` : value.length > 0 ? '' : placeholder}
            // text-base on mobile prevents iOS Safari's zoom-on-focus.
            className="min-w-[8rem] flex-1 border-0 bg-transparent p-1 text-base text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-0 sm:text-sm"
          />
        </div>

        {showHint && (
          <p id={hintId} className="mt-1 text-xs text-slate-500">
            Type at least {minQueryLength} characters
          </p>
        )}
        {atMax && (
          <p id={maxNoteId} className="mt-1 text-xs text-amber-700">
            Maximum {maxSelected} assignees
          </p>
        )}
        {errorMessage && (
          <p id={fieldErrorId} className="mt-1 text-xs text-red-600">
            {errorMessage}
          </p>
        )}

        {/* Single persistent live region: conditionally-mounted regions announce unreliably. */}
        <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
          {announcement}
        </div>

        {showPopup && (
          <div className="absolute inset-x-0 z-20 mt-1 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg">
            {isSearching && (
              <div aria-hidden="true" className="flex items-center gap-2 border-b border-slate-100 px-3 py-2 text-xs text-slate-500">
                <Spinner />
                Searching…
              </div>
            )}

            {showError && (
              <div role="alert" className="flex items-center justify-between gap-3 px-3 py-2.5 text-sm text-red-700">
                <span>
                  {search.error}
                  <span className="sr-only"> Press Enter to retry.</span>
                </span>
                <button
                  type="button"
                  onClick={handleRetry}
                  className="shrink-0 rounded-md border border-red-200 px-2.5 py-1 text-xs font-medium hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
                >
                  Retry
                </button>
              </div>
            )}

            {showEmpty && (
              <p aria-hidden="true" className="px-3 py-2.5 text-sm text-slate-500">
                {emptyMessage}
              </p>
            )}

            {!showError && (
              <ul id={listboxId} role="listbox" aria-label={label} className="max-h-60 overflow-y-auto overscroll-contain py-1">
                {optionNodes}
              </ul>
            )}
          </div>
        )}
      </div>
    );
  },
);

index.ts

export { AsyncMultiSelect } from './AsyncMultiSelect';
export { useUserSearch } from './useUserSearch';
export { parseUsers, UserSearchResponseError } from './parseUsers';
export { defaultFetchUsers } from './fetchUsers';
export type { AsyncMultiSelectProps, FetchUsers, User } from './types';

Usage with react-hook-form

import { Controller, useForm } from 'react-hook-form';
import { AsyncMultiSelect, type User } from '@/components/AsyncMultiSelect';

interface TaskForm {
  title: string;
  assignees: User[];
}

export function EditTaskForm({ task }: { task: TaskForm }) {
  const { control, handleSubmit } = useForm<TaskForm>({ defaultValues: task }); // edit mode: no fetch on mount

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))} className="space-y-4">
      <Controller
        name="assignees"
        control={control}
        rules={{ validate: (v) => v.length > 0 || 'Assign at least one person' }}
        render={({ field, fieldState }) => (
          <AsyncMultiSelect
            ref={field.ref}
            label="Assignees"
            value={field.value}
            onChange={field.onChange}
            onBlur={field.onBlur}
            errorMessage={fieldState.error?.message}
          />
        )}
      />
      <button type="submit">Save</button>
    </form>
  );
}

Tests

Config assumed: Vitest 2+, environment: 'jsdom', and a setup file with import '@testing-library/jest-dom/vitest'.

AsyncMultiSelect.test.tsx

import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AsyncMultiSelect } from './AsyncMultiSelect';
import type { AsyncMultiSelectProps, FetchUsers, User } from './types';

const alice: User = { id: 'u1', name: 'Alice Johnson', email: 'alice@taskwell.io' };
const alan: User = { id: 'u2', name: 'Alan Turing', email: 'alan@taskwell.io', avatarUrl: 'https://cdn.test/alan.png' };
const albert: User = { id: 'u3', name: 'Albert Chen', email: 'albert@taskwell.io' };

type HarnessProps = Omit<Partial<AsyncMultiSelectProps>, 'value' | 'onChange'> & {
  initialValue?: User[];
  onChangeSpy?: (users: User[]) => void;
};

/** Plays the role of the parent form: owns the selection, like RHF would. */
function Harness({ initialValue = [], onChangeSpy, ...rest }: HarnessProps) {
  const [value, setValue] = useState<User[]>(initialValue);
  return (
    <AsyncMultiSelect
      label="Assignees"
      {...rest}
      value={value}
      onChange={(next) => {
        setValue(next);
        onChangeSpy?.(next);
      }}
    />
  );
}

function deferred<T>() {
  let resolve!: (v: T) => void;
  const promise = new Promise<T>((res) => {
    resolve = res;
  });
  return { promise, resolve };
}

const setupUser = () => userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const flushDebounce = () =>
  act(async () => {
    await vi.advanceTimersByTimeAsync(300);
  });
const getInput = () => screen.getByRole('combobox', { name: 'Assignees' });

beforeEach(() => {
  // shouldAdvanceTime keeps RTL's waitFor/findBy working alongside fake timers.
  vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
  vi.useRealTimers();
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});

describe('AsyncMultiSelect', () => {
  it('renders pre-populated chips without fetching on mount (edit mode)', async () => {
    const fetchUsers = vi.fn<FetchUsers>();
    render(<Harness initialValue={[alice, alan]} fetchUsers={fetchUsers} />);

    expect(screen.getByRole('button', { name: 'Remove Alice Johnson' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Remove Alan Turing' })).toBeInTheDocument();
    await flushDebounce();
    expect(fetchUsers).not.toHaveBeenCalled();
  });

  it('shows a hint and does not search below the minimum length', async () => {
    const user = setupUser();
    const fetchUsers = vi.fn<FetchUsers>();
    render(<Harness fetchUsers={fetchUsers} />);

    await user.type(getInput(), 'a');
    expect(screen.getByText('Type at least 2 characters')).toBeInTheDocument();
    await flushDebounce();
    expect(fetchUsers).not.toHaveBeenCalled();
  });

  it('debounces the search and excludes already-selected users', async () => {
    const user = setupUser();
    const fetchUsers = vi.fn<FetchUsers>().mockResolvedValue([alice, alan, albert]);
    render(<Harness initialValue={[alan]} fetchUsers={fetchUsers} />);

    await user.type(getInput(), 'al');
    expect(fetchUsers).not.toHaveBeenCalled();

    await flushDebounce();
    expect(fetchUsers).toHaveBeenCalledTimes(1);
    expect(fetchUsers).toHaveBeenCalledWith('al', expect.any(AbortSignal));

    expect(await screen.findByRole('option', { name: /Alice Johnson/ })).toBeInTheDocument();
    expect(screen.getByRole('option', { name: /Albert Chen/ })).toBeInTheDocument();
    expect(screen.queryByRole('option', { name: /Alan Turing/ })).not.toBeInTheDocument();
  });

  it('supports ArrowDown + Enter selection and calls onChange with the appended user', async () => {
    const user = setupUser();
    const onChangeSpy = vi.fn();
    const fetchUsers = vi.fn<FetchUsers>().mockResolvedValue([alice, alan, albert]);
    render(<Harness fetchUsers={fetchUsers} onChangeSpy={onChangeSpy} />);

    const input = getInput();
    await user.type(input, 'al');
    await flushDebounce();
    const options = await screen.findAllByRole('option');
    expect(input).toHaveAttribute('aria-activedescendant', options[0]!.id);

    await user.keyboard('{ArrowDown}');
    expect(input).toHaveAttribute('aria-activedescendant', options[1]!.id);

    await user.keyboard('{Enter}');
    expect(onChangeSpy).toHaveBeenLastCalledWith([alan]);
    expect(screen.getByRole('button', { name: 'Remove Alan Turing' })).toBeInTheDocument();
    expect(input).toHaveValue('');
  });

  it('ignores a stale response that resolves after a newer one (race condition)', async () => {
    const user = setupUser();
    const first = deferred<unknown>();
    const second = deferred<unknown>();
    const fetchUsers = vi
      .fn<FetchUsers>()
      .mockReturnValueOnce(first.promise)
      .mockReturnValueOnce(second.promise);
    render(<Harness fetchUsers={fetchUsers} />);

    await user.type(getInput(), 'al');
    await flushDebounce();
    await user.type(getInput(), 'b');
    await flushDebounce();

    expect(fetchUsers).toHaveBeenCalledTimes(2);
    expect(fetchUsers.mock.calls[0]![1].aborted).toBe(true);

    await act(async () => second.resolve([albert]));
    expect(await screen.findByRole('option', { name: /Albert Chen/ })).toBeInTheDocument();

    await act(async () => first.resolve([alice, alan]));
    expect(screen.queryByRole('option', { name: /Alice Johnson/ })).not.toBeInTheDocument();
  });

  it('keeps previous results visible while a new request is loading', async () => {
    const user = setupUser();
    const fetchUsers = vi
      .fn<FetchUsers>()
      .mockResolvedValueOnce([alice, alan])
      .mockReturnValueOnce(new Promise(() => {})); // never resolves
    render(<Harness fetchUsers={fetchUsers} />);

    await user.type(getInput(), 'al');
    await flushDebounce();
    await screen.findByRole('option', { name: /Alice Johnson/ });

    await user.type(getInput(), 'i');
    await flushDebounce();
    expect(fetchUsers).toHaveBeenCalledTimes(2);
    expect(screen.getByRole('status')).toHaveTextContent('Searching');
    expect(screen.getByRole('option', { name: /Alice Johnson/ })).toBeInTheDocument();
  });

  it('shows the empty state for zero results', async () => {
    const user = setupUser();
    render(<Harness fetchUsers={vi.fn<FetchUsers>().mockResolvedValue([])} />);

    await user.type(getInput(), 'zz');
    await flushDebounce();
    await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('No users found for "zz"'));
  });

  it('shows an error with Retry on non-2xx responses (default fetcher) and recovers', async () => {
    const user = setupUser();
    const fetchMock = vi
      .fn()
      .mockResolvedValueOnce(new Response('oops', { status: 500 }))
      .mockResolvedValueOnce(
        new Response(JSON.stringify([alice]), { status: 200, headers: { 'Content-Type': 'application/json' } }),
      );
    vi.stubGlobal('fetch', fetchMock);
    render(<Harness />);

    await user.type(getInput(), 'al');
    await flushDebounce();

    const alert = await screen.findByRole('alert');
    expect(alert).toHaveTextContent(/search failed \(HTTP 500\)/i);
    expect(fetchMock).toHaveBeenCalledWith(
      '/api/users/search?q=al',
      expect.objectContaining({ signal: expect.any(AbortSignal) }),
    );

    await user.click(within(alert).getByRole('button', { name: 'Retry' }));
    expect(await screen.findByRole('option', { name: /Alice Johnson/ })).toBeInTheDocument();
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it('drops malformed items and treats a non-array payload as an error', async () => {
    vi.spyOn(console, 'warn').mockImplementation(() => {});
    const user = setupUser();
    const fetchUsers = vi
      .fn<FetchUsers>()
      .mockResolvedValueOnce([alice, { id: 'bad' }, null, { id: 'u9', name: 42, email: 'x@y.z' }, albert])
      .mockResolvedValueOnce({ users: [] });
    render(<Harness fetchUsers={fetchUsers} />);

    await user.type(getInput(), 'al');
    await flushDebounce();
    await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2));

    await user.type(getInput(), 'b');
    await flushDebounce();
    expect(await screen.findByRole('alert')).toHaveTextContent('Unexpected response from user search.');
  });

  it('removes chips via the x button and via Backspace on an empty input', async () => {
    const user = setupUser();
    const onChangeSpy = vi.fn();
    render(<Harness initialValue={[alice, alan]} fetchUsers={vi.fn<FetchUsers>()} onChangeSpy={onChangeSpy} />);

    await user.click(screen.getByRole('button', { name: 'Remove Alice Johnson' }));
    expect(onChangeSpy).toHaveBeenLastCalledWith([alan]);
    expect(getInput()).toHaveFocus();

    await user.keyboard('{Backspace}');
    expect(onChangeSpy).toHaveBeenLastCalledWith([]);
  });

  it('enforces the max limit and stops searching once reached', async () => {
    const user = setupUser();
    const nine: User[] = Array.from({ length: 9 }, (_, i) => ({ id: `m${i}`, name: `Member ${i}`, email: `m${i}@t.io` }));
    const fetchUsers = vi.fn<FetchUsers>().mockResolvedValue([alice]);
    render(<Harness initialValue={nine} fetchUsers={fetchUsers} />);

    await user.type(getInput(), 'al');
    await flushDebounce();
    await user.click(await screen.findByRole('option', { name: /Alice Johnson/ }));

    expect(screen.getByText('Maximum 10 assignees')).toBeInTheDocument();
    expect(getInput()).toHaveAttribute('readonly');

    await user.type(getInput(), 'al');
    await flushDebounce();
    expect(fetchUsers).toHaveBeenCalledTimes(1);
    expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
  });

  it('closes on Escape, Tab, and click outside', async () => {
    const user = setupUser();
    render(
      <>
        <Harness fetchUsers={vi.fn<FetchUsers>().mockResolvedValue([alice])} />
        <button type="button">Outside</button>
      </>,
    );

    await user.type(getInput(), 'al');
    await flushDebounce();
    expect(await screen.findByRole('listbox')).toBeInTheDocument();

    await user.keyboard('{Escape}');
    expect(screen.queryByRole('listbox')).not.toBeInTheDocument();

    await user.keyboard('{ArrowDown}'); // reopens
    expect(screen.getByRole('listbox')).toBeInTheDocument();
    await user.tab();
    expect(screen.queryByRole('listbox')).not.toBeInTheDocument();

    await user.click(getInput());
    expect(screen.getByRole('listbox')).toBeInTheDocument();
    await user.click(screen.getByRole('button', { name: 'Outside' }));
    expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
  });
});

Next steps / trade-offs

  • Flagged defaults to confirm with product:
    • The input clears after each selection.
    • Enter retries while the error is showing.
    • The empty state has a variant, "All matching users are already assigned", for when every result is already selected.
    • import.meta.env.DEV assumes a Vite toolchain; swap it for process.env.NODE_ENV under webpack.
  • Caching: add a small LRU keyed by query, so backspacing to a previous query is instant and saves requests. For richer invalidation, TanStack Query is an option, but it's heavier than this component's size budget.
  • Positioning: the dropdown is absolutely positioned. Inside overflow: hidden containers such as modals, it may be clipped. The fix is a portal plus viewport-aware flipping; CSS anchor positioning could replace this once Safari and Firefox support it.
  • Long lists: results are assumed to be server-capped (roughly 20–50). If they aren't, add windowing, since memoization alone won't help with 500+ rows.
  • Stabilizing callbacks: selectUser and removeUser change identity whenever value changes, which re-renders the chips and options. That's cheap at 10 items. Latest-ref callbacks would remove it if profiling ever shows a cost.
  • Size guard: add size-limit to CI with a 4KB budget, and set "sideEffects": false in the package's package.json so unused exports tree-shake.
  • Alternatives considered:
    • Downshift and react-select were ruled out by the size budget.
    • The native <datalist> supports neither multi-select, custom rows, nor async states.
    • Headless UI's Combobox is good but adds roughly 10KB or more.

Related prompts

Programming & Development

Algorithm Design Expert

Designs, analyzes, and optimizes algorithms with multiple approaches, Big-O complexity analysis, production code, and tests.

Programming & Development

Advanced Debugging Assistant

Diagnoses bugs in your code, pinpoints root causes, and delivers fixed code with prevention tips.

Programming & Development

API Development Architect

Designs a production-ready RESTful API with OpenAPI spec, secure auth, validation, and scalable architecture.

Programming & Development

Code Review Expert

Performs a rigorous senior-level code review covering correctness, design, performance, and security with prioritized, actionable fixes.