How to Create Dynamic Cross-Collection Dropdowns in Payload CMS v3

Build custom React field components to fetch cross-collection data when select options can't be async

·Matija Žiberna·
How to Create Dynamic Cross-Collection Dropdowns in Payload CMS v3

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

I was building a Shopify-style product variant system when I hit a frustrating wall. I needed dropdown options in my ProductVariants collection to dynamically pull data from my Products collection - exactly like how Shopify lets you define variant option types on products and then select from those options when creating variants. After discovering that Payload CMS v3's native select fields don't support async data fetching, I had to find a workaround. This guide shows you exactly how to build custom React field components that fetch cross-collection data and create truly dynamic dropdowns.

The Core Problem: Async Options Don't Work

The natural approach would be to use Payload's built-in select field with an async options function:

// This DOES NOT work in Payload CMS v3
{
  name: 'variantType',
  type: 'select',
  options: async ({ req }) => {
    const products = await req.payload.find({
      collection: 'products'
    })
    return products.docs.map(p => ({ label: p.name, value: p.id }))
  }
}

When you try this approach, you'll get the error: field.options.map is not a function. This happens because Payload's field initialization runs synchronously during app startup, before database connections are available. The field configuration expects an immediate array, not a Promise.

This limitation exists specifically in Payload v3 with the Drizzle adapter. The framework simply cannot handle async functions in the options property, making cross-collection dynamic dropdowns impossible through standard configuration.

The Solution: Custom React Field Components

The only reliable way to achieve dynamic cross-collection dropdowns is through custom React components that handle the async data fetching client-side. These components use Payload's field hooks to access form data and make API calls to populate dropdown options.

Let me walk you through implementing this for a product variant system where ProductVariants need to pull option types from Products.

Setting Up the Collections Structure

First, establish your collections with the proper relationships. The Products collection defines the variant option types:

// File: src/collections/Products.ts
{
  name: "variantOptionTypes",
  type: "array",
  admin: {
    condition: (data) => data.hasVariants === true,
  },
  fields: [
    {
      name: "name",
      type: "text",
      required: true,
      admin: {
        placeholder: "e.g., color, size, material",
      },
    },
    {
      name: "label",
      type: "text", 
      required: true,
      admin: {
        placeholder: "e.g., Color, Size, Material",
      },
    },
    {
      name: "values",
      type: "array",
      required: true,
      minRows: 1,
      fields: [
        {
          name: "value",
          type: "text",
          required: true,
          admin: {
            placeholder: "e.g., Red, 100g, Small",
          },
        },
      ],
    },
  ],
}

This creates a flexible structure where each product can define multiple variant option types, each with their own set of possible values. For example, a product might have "color" options with values like "Red", "Blue", "Green" and "size" options with values like "Small", "Medium", "Large".

The ProductVariants collection references these options through custom field components:

// File: src/collections/ProductVariants.ts
{
  name: "variantValues",
  type: "array",
  fields: [
    {
      name: "optionName",
      type: "text", // Must be text type for custom components
      required: true,
      admin: {
        components: {
          Field: '@/components/fields/VariantOptionTypeSelect',
        },
      },
    },
    {
      name: "value", 
      type: "text", // Must be text type for custom components
      required: true,
      admin: {
        components: {
          Field: '@/components/fields/VariantValueSelect',
        },
      },
    },
  ],
}

Notice that both fields use type: "text" rather than type: "select". This is crucial because custom field components require a base field type that Payload can manage, even though we'll render them as select dropdowns.

Building the Option Type Selector Component

The first custom component fetches variant option types from the selected product:

// File: src/components/fields/VariantOptionTypeSelect.tsx
'use client'

import React, { useEffect, useState } from 'react'
import { useFormFields, useField } from '@payloadcms/ui'

interface VariantOptionType {
  name: string
  label: string
  values: { value: string }[]
}

const VariantOptionTypeSelect: React.FC<any> = (props) => {
  const { path, field } = props
  const { setValue, value } = useField({ path })
  const [options, setOptions] = useState<{ label: string; value: string }[]>([])
  const [loading, setLoading] = useState(false)

  // Get the product ID from the form
  const productField = useFormFields(([fields]) => fields.product)
  const productId = productField?.value

  useEffect(() => {
    const loadOptions = async () => {
      if (!productId) {
        setOptions([])
        return
      }

      setLoading(true)
      try {
        // Fetch the product data via API
        const response = await fetch(`/api/products/${productId}`)
        if (response.ok) {
          const product = await response.json()
          
          if (product.variantOptionTypes && Array.isArray(product.variantOptionTypes)) {
            const optionTypeOptions = product.variantOptionTypes.map((optionType: VariantOptionType) => ({
              label: optionType.label || optionType.name,
              value: optionType.name,
            }))
            setOptions(optionTypeOptions)
          } else {
            setOptions([])
          }
        }
      } catch (error) {
        console.error('Error loading variant option types:', error)
        setOptions([])
      } finally {
        setLoading(false)
      }
    }

    loadOptions()
  }, [productId])

  if (!productId) {
    return (
      <div style={{ padding: '8px', color: '#666', fontStyle: 'italic' }}>
        Please select a product first
      </div>
    )
  }

  return (
    <div>
      <label style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
        {typeof field.label === 'string' ? field.label : field.label?.en || 'Option Type'}
      </label>
      <select
        value={value || ''}
        onChange={(e) => setValue(e.target.value)}
        disabled={loading}
        required={field.required}
        style={{
          width: '100%',
          padding: '8px',
          border: '1px solid #ccc',
          borderRadius: '4px',
        }}
      >
        <option value="">
          {loading ? 'Loading...' : 'Select option type'}
        </option>
        {options.map((option) => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
    </div>
  )
}

export default VariantOptionTypeSelect

This component demonstrates the key pattern for cross-collection data fetching in Payload. It uses useFormFields to access the product ID from the form, then makes an API call to fetch the product data. The useEffect hook ensures the options update whenever the product selection changes.

The component handles loading states and gracefully degrades when no product is selected. Using native HTML select elements instead of Payload's SelectInput component avoids React rendering issues with complex object structures.

Creating the Cascading Value Selector

The second component is more complex because it needs to watch for changes in the sibling option type field:

// File: src/components/fields/VariantValueSelect.tsx
'use client'

import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useFormFields, useField } from '@payloadcms/ui'

interface VariantOptionType {
  name: string
  label: string
  values: { value: string }[]
}

const VariantValueSelect: React.FC<any> = (props) => {
  const { path, field } = props
  const { setValue, value } = useField({ path })
  const optionNamePath = useMemo(() => {
    const segments = path.split('.')
    if (segments.length === 0) return path
    segments[segments.length - 1] = 'optionName'
    return segments.join('.')
  }, [path])
  const { value: optionNameFieldValue } = useField<string>({ path: optionNamePath })
  const [options, setOptions] = useState<{ label: string; value: string }[]>([])
  const [loading, setLoading] = useState(false)

  const productField = useFormFields(([fields]) => fields.product)
  const productId = productField?.value

  const selectedOptionName = optionNameFieldValue ?? ''
  const previousOptionNameRef = useRef<string>(selectedOptionName)

  useEffect(() => {
    if (!selectedOptionName && value) {
      setValue('')
    }
  }, [selectedOptionName, setValue, value])

  useEffect(() => {
    if (
      selectedOptionName &&
      previousOptionNameRef.current &&
      previousOptionNameRef.current !== selectedOptionName &&
      value
    ) {
      setValue('')
    }

    previousOptionNameRef.current = selectedOptionName
  }, [selectedOptionName, setValue, value])

  useEffect(() => {
    if (value && (!options.length || !options.some((option) => option.value === value))) {
      setValue('')
    }
  }, [options, setValue, value])

  useEffect(() => {
    const loadOptions = async () => {
      if (!productId || !selectedOptionName) {
        setLoading(false)
        setOptions([])
        return
      }

      setLoading(true)
      try {
        const response = await fetch(`/api/products/${productId}`)
        if (response.ok) {
          const product = await response.json()

          if (product.variantOptionTypes && Array.isArray(product.variantOptionTypes)) {
            const optionType = product.variantOptionTypes.find(
              (ot: VariantOptionType) => ot.name === selectedOptionName
            )

            if (optionType?.values && Array.isArray(optionType.values)) {
              const valueOptions = optionType.values.map((valueObj: any) => ({
                label: valueObj.value,
                value: valueObj.value,
              }))
              setOptions(valueOptions)
            } else {
              setOptions([])
            }
          } else {
            setOptions([])
          }
        } else {
          setOptions([])
        }
      } catch (error) {
        console.error('Error loading variant values:', error)
        setOptions([])
      } finally {
        setLoading(false)
      }
    }

    loadOptions()
  }, [productId, selectedOptionName])

  if (!productId) {
    return (
      <div style={{ padding: '8px', color: '#666', fontStyle: 'italic' }}>
        Please select a product first
      </div>
    )
  }

  if (!selectedOptionName) {
    return (
      <div style={{ padding: '8px', color: '#666', fontStyle: 'italic' }}>
        Please select an option type first
      </div>
    )
  }

  return (
    <div>
      <label style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
        {typeof field.label === 'string' ? field.label : field.label?.en || 'Value'}
      </label>
      <select
        value={value || ''}
        onChange={(e) => setValue(e.target.value)}
        disabled={loading}
        required={field.required}
        style={{
          width: '100%',
          padding: '8px',
          border: '1px solid #ccc',
          borderRadius: '4px',
        }}
      >
        <option value="">
          {loading ? 'Loading...' : 'Select value'}
        </option>
        {options.map((option) => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
    </div>
  )
}

export default VariantValueSelect

By deriving the sibling optionName path with useMemo and subscribing to it via useField, the component no longer relies on DOM polling or timers. Every time the option type changes we clear incompatible selections, fetch the correct values, and keep the form state consistent, eliminating the infinite render loop that the earlier DOM approach triggered.

The Complete User Experience

With both components implemented, users get a seamless Shopify-style experience:

  1. Select a product in the ProductVariants form
  2. The option type dropdown immediately populates with types defined on that product
  3. Select an option type (like "color")
  4. The value dropdown appears with only the values for that option type ("Red", "Blue", "Green")
  5. Server-side validation ensures all combinations are unique and match the product definitions

This creates truly dynamic cross-collection relationships that weren't possible with Payload's native field options. Because the cascading logic now stays entirely inside Payload's form state, the UI remains snappy and predictable even as rows are added, reordered, or removed.

Hardening the Server-Side Validation

On the backend, remember that relationship fields can arrive as strings, numbers, objects, or nested arrays depending on how Payload serialises them. I introduced a tiny helper in ProductVariants.ts:

const resolveRelationshipId = (input: unknown): string | number | undefined => {
  // …normalises strings, numbers, arrays, and objects with id/value keys…
}

Every hook now calls resolveRelationshipId(data.product) before querying Payload. That single change eliminated the 404 warnings, the NaN parameters in duplicate checks, and the failure to update hasVariants. Combine that with the updated field components and the entire create/update flow is finally clean.

Key Takeaways

When you need dynamic dropdown options from other collections in Payload CMS v3, native select fields still can’t fetch async data. Custom React field components remain the answer, but you don’t have to fall back to DOM polling—useField and useMemo give you everything you need to react to sibling values safely.

Pair the declarative client-side approach with resilient server hooks (normalising relationship IDs before querying) and you’ll have production-ready cascading dropdowns without console noise or infinite render loops.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in