I spent three hours one Friday afternoon wrestling with Payload CMS's form system, trying to build a product comparison table for an e-commerce site. The feature required managing 24 specifications across 7 product variants—168 data points that users needed to edit and compare. Payload's default nested array UI showed each variant in a collapsed accordion, making it impossible to see more than one specification at a time. Comparing values across products meant clicking through endless accordions and memorizing data.
After struggling with persistence bugs, performance issues, and Payload's underdocumented form hooks, I finally cracked it. The solution was a custom table-based field component that replaces Payload's array UI with a spreadsheet-like interface, complete with CSV import/export—all client-side, no server hooks or blob storage needed.
This guide shows you exactly how to build production-grade custom array field components in Payload CMS. You'll learn the patterns that work, the gotchas to avoid, and how to deeply integrate with Payload's form system for features like validation, dirty state tracking, and auto-save.
The Problem: Payload's Array UI Doesn't Work for Tabular Data
Let's say you're building a product specification comparison feature. You have:
Inside each accordion, another nested accordion for variantData
You have to click through multiple levels to see a single value
Specific UX problems:
Can't see all data at once - Only one accordion is expanded at a time
Can't compare across variants - Values are buried in different accordions
No visual relationship - The connection between spec rows and variant values is invisible
Manual entry is painful - Entering 168 data points one-by-one in nested accordions is tedious
What we need instead:
A horizontal table where specifications are columns, variants are rows, and every cell is editable inline. Think Google Sheets, not nested forms.
Understanding Payload's Custom Field Components
Before we build anything, you need to understand how Payload lets you replace its default UI.
The admin.components.Field Pattern
In any Payload field definition, you can specify a custom React component to render instead of the default UI:
typescript
{
name: 'myArrayField',
type: 'array',
admin: {
components: {
Field: '/src/components/payload/custom/MyCustomField'
}
},
fields: [
// Field schema still required for data structure
]
}
Important: The fields array is still required. It defines the data schema. Your custom component only controls the UI.
Payload's Form Hooks: The Foundation
Custom field components use three main hooks from @payloadcms/ui:
1. useField()
Binds a single input to Payload's form state. This is how you get validation, dirty state tracking, and persistence.
Reads values from other fields in the form. Use this when your component depends on sibling fields.
typescript
import { useFormFields } from'@payloadcms/ui'constMyComponent = () => {
// Good: Only re-renders when this specific field changesconst rows = useFormFields(([fields]) => fields['layout.0.rows'])
// Bad: Re-renders on EVERY form changeconst allFields = useFormFields(([fields]) => fields)
}
Performance tip: Always use a selector function to avoid re-rendering on every keystroke in the entire form.
TypeScript Types
Your component should use the ArrayFieldClientComponent type:
Let's start by building a single-column table for managing specification names. This establishes the core patterns we'll reuse for more complex tables.
Flattened format: variants.0.id, variants.0.title, etc.
Your component needs to handle both:
typescript
constRowsField: ArrayFieldClientComponent = (props) => {
const { field, path } = props
const { setValue } = useField({ path })
// Get all form fieldsconst allFields = useFormFields(([fields]) => fields)
// Get the field value (could be array or number representing length)const rowsField = allFields[path]
const rowsValue = rowsField?.value// Reconstruct rows from either formatconst rows = useMemo(() => {
if (Array.isArray(rowsValue)) {
return rowsValue // Already an array
}
// Flattened format: reconstruct from individual fieldsconst rowsLength = typeof rowsValue === 'number' ? rowsValue : 0const reconstructed = []
for (let i = 0; i < rowsLength; i++) {
const idField = allFields[`${path}.${i}.id`]
const nameField = allFields[`${path}.${i}.name`]
if (idField || nameField) {
reconstructed.push({
id: String(idField?.value || ''),
name: String(nameField?.value || ''),
})
}
}
return reconstructed
}, [rowsValue, allFields, path])
return<div>{/* Use rows array here */}</div>
}
Why this is necessary: Payload's internal form state may represent your array differently based on whether it's being edited, validated, or saved. This dual-mode reading ensures your component always gets the data.
Add/Remove Operations
Use addFieldRow() and removeFieldRow() from useForm():
Critical: Pass schemaPath to addFieldRow(). This tells Payload to initialize the new row using your field schema, including default values and nested structures.
Inline Editing with TextCell
Here's where it gets interesting. You need a sub-component for each editable cell that uses useField():
typescript
// TextCell: A single editable input bound to Payload's formconstTextCell = ({
path,
placeholder
}: {
path: string
placeholder?: string
}) => {
const { value, setValue } = useField<string>({ path })
const [localValue, setLocalValue] = useState(value || '')
// Sync local state when external value changes (e.g., initial load)useEffect(() => {
if (value !== undefined && value !== localValue) {
setLocalValue(value)
}
}, [value])
consthandleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.valuesetLocalValue(newVal) // Instant local feedbacksetValue(newVal) // Update Payload form state
}
return (
<inputtype="text"value={localValue}onChange={handleChange}placeholder={placeholder}style={{width: '100%',
padding: '8px',
border: '1pxsolidvar(--theme-elevation-200)',
borderRadius: '3px',
fontSize: '13px',
}}
/>
)
}
Why we need local state: Direct binding to value and setValue works, but we'll add debouncing next (see Performance section) which requires local state for instant UI feedback.
The path pattern:${path}.${rowIndex}.name constructs the full field path like layout.0.rows.2.name. Payload uses this to track each cell independently.
The Debouncing Problem (Preview)
If you test the component above, you'll notice typing feels sluggish with large tables. Every keystroke triggers a setValue() call, which can trigger validation or auto-save. We'll fix this in the Performance section with a debouncing hook.
Adding CSV Import/Export
Now let's add bulk operations. We'll implement three features:
Download Template - Headers only, for users to fill in
Export CSV - Current data as CSV
Import CSV - Upload and parse CSV files
All client-side, no server needed.
CSV Helper Functions
First, we need utilities for escaping and parsing CSV data (RFC 4180 compliant):
typescript
// Escape CSV values (handles commas, quotes, newlines)constescapeCSV = (str: string) => {
if (str === null || str === undefined) return''const stringValue = String(str)
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
return`"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
// Parse CSV line respecting quoted values
const parseCSVLine = (line: string): string[] => {
const result = []
let startValueIndex = 0
let inQuotes = false
for (let i = 0; i < line.length; i++) {
if (line[i] === '"') {
inQuotes = !inQuotes
} else if (line[i] === ',' && !inQuotes) {
let value = line.substring(startValueIndex, i).trim()
// Remove surrounding quotes and unescape double quotes
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1).replace(/""/g, '"')
}
result.push(value)
startValueIndex = i + 1
}
}
// Push last value
let value = line.substring(startValueIndex).trim()
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1).replace(/""/g, '"')
}
result.push(value)
return result
}
Why custom parsing? JavaScript's String.split(',') doesn't handle quoted values containing commas. These functions correctly parse "Value, with comma",OtherValue.
CSV Export
Generate CSV content and download it using a Blob:
The download pattern: Create a temporary Blob URL, attach it to a hidden anchor element, trigger click, then clean up. This works across all modern browsers.
CSV Import
Read uploaded files with FileReader and parse them:
typescript
constRowsField: ArrayFieldClientComponent = (props) => {
const { path } = props
const fileInputRef = useRef<HTMLInputElement>(null)
const { setValue } = useField({ path })
consthandleImportClick = (e: React.MouseEvent) => {
e.preventDefault()
fileInputRef.current?.click()
}
consthandleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) returnconst reader = newFileReader()
reader.onload = (event) => {
const text = event.target?.resultasstringif (!text) returnconst lines = text.split(/\r?\n/).filter(line => line.trim() !== '')
if (lines.length < 2) return// Need at least headers and one row// Skip header row, process data rowsconst newRows = []
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i])
if (values.length === 0) continue
newRows.push({
id: generateId(),
name: values[0]
})
}
// Atomic update: append to existing rowsconst updatedRows = [...rows, ...newRows]
setValue(updatedRows)
// Reset file inputif (fileInputRef.current) fileInputRef.current.value = ''
}
reader.readAsText(file)
}
return (
<div><ButtononClick={handleImportClick}>Import CSV</Button><inputtype="file"ref={fileInputRef}style={{display: 'none' }}
accept=".csv"onChange={handleFileChange}
/></div>
)
}
Why setValue() instead of multiple addFieldRow() calls?
I initially tried calling addFieldRow() for each CSV row with a data parameter. It created empty rows—the data didn't populate. The issue: when using custom components, Payload's internal row creation doesn't reliably hydrate nested data, especially when child inputs use useField() hooks that haven't mounted yet.
The solution: Use setValue() to atomically update the entire array. This bypasses Payload's row creation reducer and directly sets the value, which then flows down to all the useField() hooks in your TextCell components.
Advanced: Multi-Column Tables (VariantsField)
Now for the complex part: a table where columns are dynamically generated from a sibling field.
Path construction: If your variants field is at layout.0.variants, the rows field is at layout.0.rows. We split the path, remove the last segment, and append rows.
Auto-Sync Mechanism
When specification rows change, we need to update all variants' variantData arrays to match:
typescript
constVariantsField: ArrayFieldClientComponent = (props) => {
const { path } = props
const { addFieldRow, removeFieldRow } = useForm()
// ... existing code to get rows and variants ...// Auto-sync: When rows.length changes, update all variantsuseEffect(() => {
if (variants.length === 0) return
variants.forEach((variant, vIndex) => {
const currentData = variant.variantData || []
const diff = rows.length - currentData.lengthconst variantDataPath = `${path}.${vIndex}.variantData`if (diff > 0) {
// Add missing rowsfor (let i = 0; i < diff; i++) {
addFieldRow({
path: variantDataPath,
schemaPath: variantDataPath,
})
}
} elseif (diff < 0) {
// Remove extra rows from the endfor (let i = 0; i < Math.abs(diff); i++) {
removeFieldRow({
path: variantDataPath,
rowIndex: currentData.length - 1 - i
})
}
}
})
}, [rows.length, variants.length, path, addFieldRow, removeFieldRow])
}
Why this works: We watch rows.length, and whenever it changes, we surgically add or remove items from each variant's variantData array. This keeps them synchronized.
Important: We only watch the length dependency, not the entire rows array. If we watched the full array, this effect would fire on every row name change, causing unnecessary operations.
The nested path:${path}.${variantIndex}.variantData.${specIndex}.value constructs paths like layout.0.variants.2.variantData.5.value. Each TextCell uses useField() with this path, binding it to Payload's form.
CSV Import for Multi-Column Tables
The pattern is similar, but we need header mapping:
Smart mapping: We find CSV columns by name (case-insensitive for "title", exact match for spec names). This allows users to reorder columns in their CSV.
Performance Optimizations
With large tables (100+ cells), performance becomes critical. Here's how to keep the UI responsive.
The Debouncing Pattern
Every keystroke in a TextCell calls setValue(), which can trigger:
Form validation
Dirty state updates
Auto-save network requests
With 168 cells, this creates massive overhead.
Solution: Debounce the setValue() calls with a custom hook:
User types "A" → localValue updates instantly (no lag)
Debounce timer starts (300ms)
User types "B" → localValue updates, timer resets
User types "C" → localValue updates, timer resets
User stops typing for 300ms → setValue() finally fires once with "ABC"
Result: Typing feels instant, but Payload only processes one update per word instead of per keystroke.
Why 300ms? It's long enough to batch keystrokes but short enough that users don't notice a delay when clicking away.
Memoization
Prevent unnecessary recalculations with useMemo:
typescript
const rows = useMemo(() => {
// Reconstruct rows from form fields// Only recalculates when dependencies change
}, [rowsValue, allFields, path])
const specColumns = useMemo(() => {
return rows.map((row) => ({
id: row.id,
name: row.name || 'Untitled',
}))
}, [rows])
Without memoization: Every render (caused by any form change) would rebuild these arrays, causing the table to re-render even when data hasn't changed.
With memoization: Arrays are only rebuilt when their dependencies change, preventing unnecessary renders.
Sticky Table Headers and Columns
For large tables, use CSS position: sticky to keep headers and key columns visible:
Import path: Use /src/components/payload/custom/ComponentName (no .tsx extension)
Keep fields array: The fields array defines your data schema and is required
Your component only controls UI: The schema still validates and structures data
Generate Import Map
Payload needs to build a module map for your custom components. Run:
bash
pnpm payload generate:importmap
This creates src/app/(payload)/admin/importMap.js with references to your components.
When to regenerate:
After adding new custom components
After changing component file paths
After git pull if teammates added components
Troubleshooting: If your component doesn't appear in the admin UI, regenerate the import map and restart your dev server.
Common Issues and Solutions
Here are the problems you'll likely encounter and how to fix them.
1. Changes Not Persisting
Symptom: You type in an input, but when you save or refresh, the data is gone.
Cause: Your input isn't using useField(), so Payload doesn't know about the changes.
Solution: Every input must use useField():
typescript
// ❌ Wrong: Direct state, not bound to Payloadconst [value, setValue] = useState('')
<input value={value} onChange={(e) =>setValue(e.target.value)} />
// ✅ Correct: Bound to Payload form stateconst { value, setValue } = useField({ path: 'myField' })
<input value={value} onChange={(e) =>setValue(e.target.value)} />
2. Input Lag While Typing
Symptom: There's a noticeable delay between pressing keys and seeing characters appear.
Cause: Direct setValue() calls trigger heavyweight form operations on every keystroke.
Solution: Implement debouncing as shown in the Performance section. Use local state for immediate feedback and debounce the setValue() call.
3. CSV Import Creates Empty Rows
Symptom: Importing CSV adds the right number of rows, but cells are empty.
Cause: Using addFieldRow({ path, data: {...} }) doesn't reliably populate nested data when child inputs use useField() hooks.
Solution: Use setValue() for bulk operations:
typescript
// ❌ Wrong: addFieldRow with data
newRows.forEach(row => {
addFieldRow({ path, data: row })
})
// ✅ Correct: Atomic update with setValueconst updatedRows = [...existingRows, ...newRows]
setValue(updatedRows)
4. Can't Read Sibling Field Values
Symptom: Your component needs data from another field, but it's undefined.
Cause: Incorrect path construction or not using useFormFields().
Solution: Use useFormFields() with correct path:
typescript
const allFields = useFormFields(([fields]) => fields)
// If your field is at 'layout.0.variants'// And you need 'layout.0.rows'const basePath = path.split('.').slice(0, -1).join('.') // 'layout.0'const rowsPath = basePath ? `${basePath}.rows` : 'rows'const rowsField = allFields[rowsPath]
5. Nested Arrays Out of Sync
Symptom: You add a specification row, but variant data doesn't update with a new column.
Cause: No synchronization between the two arrays.
Solution: Implement an auto-sync useEffect as shown in the VariantsField section:
typescript
useEffect(() => {
// When rows.length changes, update all variants' variantData
variants.forEach((variant, index) => {
const diff = rows.length - variant.variantData.length// Add or remove rows to match
})
}, [rows.length]) // Only watch length, not full array
Conclusion
Payload's default array UI works great for simple lists, but it breaks down for tabular data. Custom table-based field components solve this by giving you full control over the UI while deeply integrating with Payload's form system.
What we covered:
The problem: Nested accordions don't work for comparing data across rows
The foundation:useField(), useForm(), and useFormFields() hooks
Simple tables: Building RowsField with add/remove/edit operations
CSV operations: Client-side import/export with robust parsing
Complex tables: VariantsField with dynamic columns and auto-sync
Performance: Debouncing inputs and memoizing derived data
Registration: Connecting custom components to Payload's admin
Key learnings:
useField() for every input = Persistence + validation + dirty state tracking
Debouncing = Performance with auto-save forms
setValue() for bulk updates = Reliable when addFieldRow() fails with nested data
Auto-sync effects = Maintaining relationships between fields
Memoization = Preventing unnecessary re-renders
You now have the patterns to build production-grade custom field components for any tabular data in Payload CMS. Whether it's product specifications, pricing tables, feature comparisons, or scheduling grids—the principles are the same.
If you run into issues or have questions, feel free to open an issue in your project repo. Good luck building!
For the broader set of admin UI components available in @payloadcms/ui — field wrappers, layout primitives, typography — the Payload CMS Admin UI Components glossary is a useful reference. The custom admin UI components guide covers the full workflow for discovering and building any custom field type. And if you want to understand what a production Payload project costs end to end, the Payload CMS pricing guide breaks it all down.