component — conform

Conform Async Multiple Select

Async Multiple Select wired to Conform: derives name and validity from field metadata, mirrors the remote-loaded selection into hidden inputs for submission, and renders inline errors.

  • conform
  • select
  • multi-select
  • async
  • form
  • validation
  • tags
  • search

01 example

Bound to a Conform form

Submit without selecting anyone to see the validation error wired from field metadata. Options load from the source as you search.

source

conform-async-multiple-select.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import type { FieldMetadata } from "@conform-to/react"
import { useMemo } from "react"
import { cn } from "@/lib/utils"
import {
  AsyncMultipleSelect,
  type AsyncMultipleSelectOption,
  type AsyncMultipleSelectProps,
} from "@/components/async-multiple-select"
import { describedBy, Description, Field, FieldError, Label } from "@/components/field"

export interface ConformAsyncMultipleSelectProps<T extends AsyncMultipleSelectOption>
  extends Omit<
    AsyncMultipleSelectProps<T>,
    "name" | "form" | "id" | "value" | "defaultValue" | "isInvalid" | "aria-describedby"
  > {
  /** A multi-value select bound to a string-array form value — the chosen options' ids. */
  field: FieldMetadata<string[]>
  label?: string
  description?: string
  /**
   * Initial selection as full option objects so tags render labels. Falls back
   * to the field's initial ids (label = id) when omitted — pass this whenever
   * the label differs from the stored id.
   */
  defaultSelected?: T[]
}

/**
 * ConformAsyncMultipleSelect — Async Multiple Select wired to Conform.
 *
 * Binds a multi-value Conform field to the quebi Async Multiple Select: derives
 * name, id, form, and validity from the field metadata, mirrors the selection
 * into hidden inputs for submission, and renders inline errors. Provide `load`
 * to fetch options and `defaultSelected` to seed labelled tags from the field's
 * initial value.
 */
export function ConformAsyncMultipleSelect<T extends AsyncMultipleSelectOption>({
  field,
  label,
  description,
  defaultSelected,
  className,
  ...props
}: ConformAsyncMultipleSelectProps<T>) {
  const defaultValue = useMemo<T[]>(() => {
    if (defaultSelected) return defaultSelected
    const initial = (field.initialValue as string[] | undefined) ?? []
    return initial.map((id) => ({ id, name: id }) as T)
  }, [defaultSelected, field.initialValue])

  const hasErrors = !field.valid && !!field.errors
  const isRequired = field.required ?? false

  return (
    <Field className={cn(className)}>
      {label && (
        <Label htmlFor={field.id} className={cn(hasErrors && "text-quebi-danger")}>
          {label}
          {isRequired && <span className="ml-1 text-quebi-fg">*</span>}
        </Label>
      )}
      <AsyncMultipleSelect<T>
        {...props}
        id={field.id}
        name={field.name}
        form={field.formId}
        defaultValue={defaultValue}
        isInvalid={hasErrors}
        aria-describedby={describedBy(
          hasErrors && field.errorId,
          description && field.descriptionId,
        )}
      />
      {/* These ids are ours to set: AsyncMultipleSelect is a hand-built
          combobox, not a react-aria field, so nothing generates them and the
          aria-describedby above is their only reference. */}
      {description && <Description id={field.descriptionId}>{description}</Description>}
      {hasErrors && <FieldError id={field.errorId}>{field.errors?.join(", ")}</FieldError>}
    </Field>
  )
}