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

📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
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:
- Select a product in the ProductVariants form
- The option type dropdown immediately populates with types defined on that product
- Select an option type (like "color")
- The value dropdown appears with only the values for that option type ("Red", "Blue", "Green")
- 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
Comments
You might be interested in

1st October 2025

26th September 2025

25th September 2025