---
title: "Payload CMS Admin UI: Custom Components with @payloadcms/ui"
slug: "payload-cms-custom-admin-ui-components-guide"
published: "2025-10-16"
updated: "2026-03-28"
validated: "2026-03-21"
categories:
  - "Payload"
tags:
  - "Payload CMS"
  - "@payloadcms/ui"
  - "admin UI"
  - "React"
  - "custom fields"
  - "Payload components"
  - "TypeScript"
  - "custom admin view"
  - "useField"
  - "useConfig"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "payload cms"
  - "@payloadcms/ui"
  - "react"
  - "typescript"
status: "stable"
llm-purpose: "Step-by-step guide to creating custom Payload CMS v3 admin interfaces with @payloadcms/ui components, including forms, layouts, data fetching, more"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to @payloadcms/ui"
  - "Access to React"
  - "Access to TypeScript"
llm-outputs:
  - "Completed outcome: Step-by-step guide to creating custom Payload CMS v3 admin interfaces with @payloadcms/ui components, including forms, layouts, data fetching, more"
---

**Summary Triples**
- (Article, explains, how to discover and use components from the undocumented @payloadcms/ui package to build native-feeling admin UI in Payload v3)
- (@payloadcms/ui, is discoverable via, TypeScript definition files inside node_modules and demo projects)
- (Custom components, should import, primitives from @payloadcms/ui to match Payload admin styles and layout)
- (Bulk inventory field example, demonstrates, composing forms, sortable tables, and layout primitives from @payloadcms/ui to ship a production-ready custom field)
- (Prototyping workflow, can be accelerated with, a shared-database workflow and live preview on Vercel (referenced external guide))
- (No official docs, means, developers should inspect TypeScript types, demo repos, and Discord threads to learn available components and props)
- (Design consistency, is achieved by, composing @payloadcms/ui primitives rather than raw HTML controls)

### {GOAL}
Step-by-step guide to creating custom Payload CMS v3 admin interfaces with @payloadcms/ui components, including forms, layouts, data fetching, more

### {PREREQS}
- Access to Payload CMS
- Access to @payloadcms/ui
- Access to React
- Access to TypeScript

### {STEPS}
1. Inventory Payload UI building blocks
2. Bootstrap a custom field with useField
3. Compose native form primitives
4. Organize layout with collapsibles and buttons
5. Add sortable tables and shared styles
6. Handle data fetching and states

<!-- llm:goal="Step-by-step guide to creating custom Payload CMS v3 admin interfaces with @payloadcms/ui components, including forms, layouts, data fetching, more" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to @payloadcms/ui" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:output="Completed outcome: Step-by-step guide to creating custom Payload CMS v3 admin interfaces with @payloadcms/ui components, including forms, layouts, data fetching, more" -->

# Payload CMS Admin UI: Custom Components with @payloadcms/ui
> Complete guide to @payloadcms/ui components in Payload CMS v3. Build custom admin fields, views, and layouts with working TypeScript examples.
Matija Žiberna · 2025-10-16

When I started building custom fields for Payload CMS v3, I quickly hit a wall. The `@payloadcms/ui` package exists, it's actively maintained, and it's right there in my node_modules. But there's no official documentation. Zero. I spent hours digging through TypeScript definitions, inspecting demo projects, and scrolling through Discord threads trying to figure out which components were available and how to use them.

After successfully building a production-ready bulk inventory field component, I realized this knowledge needed to be shared. This guide shows you exactly how to discover and use Payload's native UI components to build custom admin interfaces that look and feel like they belong in Payload. By the end, you'll know how to explore the UI package yourself and build professional custom fields without fighting the admin panel's design system. If you also need a fast iteration setup while you prototype these components, I walk through the shared-database workflow in [How to Set Up Payload CMS for Instant Development Iteration and Live Preview on Vercel](https://www.buildwithmatija.com/blog/payload-cms-instant-development-workflow).

## What's in This Guide

A quick reference before diving in — here's what `@payloadcms/ui` gives you to work with:

| Component / Hook | What it does | Category |
|---|---|---|
| `Button` | Styled buttons with multiple variants and icon support | Elements |
| `Collapsible` | Expandable/collapsible content sections | Elements |
| `ReactSelect` | Styled dropdown with search, clear, and multi-select | Elements |
| `DatePickerField` | Full calendar date picker | Elements |
| `DraggableSortable` | Drag-and-drop sortable list with keyboard support | Elements |
| `TextInput` | Payload-styled text/number input | Fields |
| `FieldLabel` / `FieldDescription` | Consistent labels and helper text | Fields |
| `Popup` / `PopupButtonList` | Dropdown menus with smart positioning | Elements |
| `Banner` / `Pill` / `toast` | Alerts, status badges, and toast notifications | Elements |
| `DocumentControls` / `StickyToolbar` | Save/publish controls and sticky document header | Admin |
| `useField` | Connect a custom component to Payload's form state | Hook |
| `useConfig` | Access the current Payload config on the client | Hook |
| `useAuth` | Access the currently authenticated user | Hook |

All imports come from the flat `'@payloadcms/ui'` entry point — no subpath imports needed.

## The Problem: Custom Fields That Don't Belong

I needed to build a bulk entry component for inventory batches. Users needed to add multiple products with variants, delivery dates, stock quantities, and batch numbers all in one form. A simple array field wouldn't cut it, and building everything with basic HTML inputs would look terrible and feel disconnected from Payload's polish.

The challenge wasn't just making it work functionally. It was making it feel native. Custom components built with raw HTML inputs stick out like sore thumbs in Payload's admin panel. Different spacing, inconsistent button styles, mismatched form controls. It screams "third-party component" and creates a jarring user experience.

I knew Payload must have reusable UI components internally. After all, every select dropdown, every collapsible section, every button in the admin panel uses consistent styling. But without documentation, I needed to reverse-engineer the entire UI package.

## Discovering Available Components

The first step was understanding what components actually existed. I navigated to my project's node_modules and found the @payloadcms/ui package structure:

```
node_modules/@payloadcms/ui/dist/
├── elements/
│   ├── Button/
│   ├── Collapsible/
│   ├── ReactSelect/
│   ├── DatePicker/
│   ├── DraggableSortable/
│   └── ...
└── fields/
    ├── Text/
    ├── Number/
    ├── Select/
    ├── FieldLabel/
    ├── FieldDescription/
    └── ...
```

The package is organized into two main categories. The `elements` directory contains building blocks like buttons, modals, and interactive components. The `fields` directory contains complete field components and their subcomponents like labels and descriptions.

Each component folder includes TypeScript definition files. These `.d.ts` files became my documentation. For example, checking `Button/types.d.ts` revealed all available props:

```typescript
// From @payloadcms/ui/dist/elements/Button/types.d.ts
export type Props = {
  buttonStyle?: 'error' | 'icon-label' | 'none' | 'pill' | 'primary' | 'secondary' | 'subtle' | 'tab' | 'transparent';
  children?: React.ReactNode;
  disabled?: boolean;
  icon?: ['chevron' | 'edit' | 'plus' | 'x'] | React.ReactNode;
  iconPosition?: 'left' | 'right';
  iconStyle?: 'none' | 'with-border' | 'without-border';
  onClick?: (event: MouseEvent) => void;
  size?: 'large' | 'medium' | 'small' | 'xsmall';
  // ... more props
}
```

This pattern worked for every component. The type definitions tell you exactly what props are available, what values they accept, and often include helpful comments. It's not as good as real documentation, but it's comprehensive.

> **Import note:** Exploring subpaths in `dist` is how you discover what's available, but always import using the flat `'@payloadcms/ui'` entry point in your actual code. Path-specific imports like `@payloadcms/ui/elements/Button` break with current Payload v3 releases and cause a `Module not found` error.

## Building the Foundation: Imports and Structure

I started my custom field component by importing the core UI components I'd need. In current Payload v3, all components and hooks are exported from the flat `'@payloadcms/ui'` entry point — no subpath imports needed:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
'use client'

import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
  useField,
  Button,
  FieldLabel,
  FieldDescription,
  Collapsible,
  ReactSelect,
  TextInput,
  DatePickerField,
  DraggableSortable,
} from '@payloadcms/ui'
import type { Option } from '@payloadcms/ui'
```

The `useField` hook is essential for any custom field component. It connects your component to Payload's form state management, giving you access to the field's value and a setter function. This is how your custom component integrates with Payload's data flow:

```typescript
const { value, setValue } = useField<BulkRowPayload[]>({ path: 'bulkRows' })
```

This hook manages the connection between your UI component and the form data. When you call `setValue`, Payload updates the form state. When the form loads existing data, `value` provides it to your component. The type parameter ensures type safety for your specific data structure.

## Using Native Form Components

One of the biggest wins was replacing basic HTML inputs with Payload's native form components. Instead of fighting with custom styling, these components automatically match the admin panel's design system.

### Select Dropdowns with ReactSelect

Payload uses react-select internally, but wraps it with their own component that handles styling and behavior. Here's how I replaced a basic HTML select:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
<FieldLabel label="Product" />
<ReactSelect
  value={
    row.productId
      ? { label: row.productLabel || `Product ${row.productId}`, value: row.productId }
      : undefined
  }
  onChange={(selected) => {
    const option = selected as Option<number> | null
    if (!option || !option.value) {
      // Handle clearing
      return
    }
    const nextProductId = option.value as number
    // Update state
  }}
  options={products.map((product) => ({
    label: product.title,
    value: product.id,
  }))}
  isClearable
  isSearchable
  placeholder="Select product…"
/>
```

The ReactSelect component provides built-in search functionality, keyboard navigation, and a clear button. The options format is straightforward: an array of objects with `label` and `value` properties. The component handles all the complex dropdown behavior while maintaining Payload's visual style.

What makes this powerful is how it handles the value. You pass an option object matching the format of your options array. When the user changes the selection, onChange receives the selected option object. For clearing, it receives null. This consistent API makes it predictable to work with.

### Text Inputs with Proper Styling

For text and number inputs, Payload provides TextInput components that match the admin panel's input styling:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
<FieldLabel label="Total stock" />
<TextInput
  path={`${row.id}-totalStock`}
  value={row.totalStock?.toString() ?? ''}
  onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
    const nextValue = e.target.value
    updateRow(row.id, (current) => ({
      ...current,
      totalStock: nextValue === '' ? undefined : Number(nextValue),
    }))
  }}
  placeholder="Enter quantity"
/>
```

The TextInput component requires a `path` prop for Payload's field tracking system. Even though this is a custom component managing its own state, providing unique paths for each input ensures proper React key management and helps with debugging. The onChange handler receives a standard React change event, making it familiar to work with.

### Date Pickers with Calendar UI

Instead of basic HTML date inputs, Payload provides DatePickerField with a full calendar interface:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
<FieldLabel label="Received date" />
<DatePickerField
  value={row.receivedDate}
  onChange={(date) => {
    const dateValue = date ? formatDateForInput(date) : todayInputValue
    updateRow(row.id, (current) => ({
      ...current,
      receivedDate: dateValue,
    }))
  }}
  placeholder="Select received date"
/>
```

The DatePickerField handles date parsing and formatting automatically. It accepts both Date objects and ISO strings as values, and the onChange callback receives a Date object. This eliminates the need to manually handle date string formatting and provides users with a much better experience than typing dates into a text field.

## Labels and Descriptions

Every field needs proper labels and helper text. Payload provides dedicated components that ensure consistent typography and spacing:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
<FieldLabel label="Unit" />
<ReactSelect /* ... */ />
{productOption && (
  <FieldDescription
    path={`${row.id}-unit-locked`}
    description="Unit is set by the product configuration"
  />
)}
```

The FieldLabel component handles proper label styling and positioning. It's a simple wrapper, but using it ensures your labels match every other field in Payload. The FieldDescription component displays helper text below fields with appropriate styling and color. Like TextInput, it requires a path prop for proper component management.

These small details matter. When every part of your custom field uses the same label fonts, description colors, and spacing as native Payload fields, the component feels integrated rather than bolted on.

## Organizing Content with Collapsible Sections

For complex forms with many fields, collapsible sections improve usability. Payload's Collapsible component provides exactly this:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
<Collapsible
  key={row.id}
  initCollapsed={false}
  header={<span>{rowTitle}</span>}
>
  <div
    style={{
      display: 'grid',
      gridTemplateColumns: 'repeat(6, minmax(0, 1fr))',
      gap: 12,
      padding: 12,
    }}
  >
    {/* All form fields */}
  </div>
</Collapsible>
```

The Collapsible component wraps content that can be toggled. The header prop defines what's always visible, and children define the collapsible content. Setting `initCollapsed={false}` means sections start expanded. Users can click the header to collapse sections they're not actively editing, reducing visual clutter on forms with many rows.

The component includes all the necessary accessibility attributes and keyboard handling automatically. The expand and collapse animations are smooth and match the rest of Payload's interface. This creates a professional feel that would take significant custom CSS to replicate.

## Buttons with Consistent Styling

Payload provides a comprehensive Button component with multiple style variants:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
<Button
  buttonStyle="secondary"
  size="small"
  onClick={() => duplicateRow(row.id)}
>
  Duplicate Row
</Button>

<Button
  buttonStyle="error"
  size="small"
  onClick={() => removeRow(row.id)}
  disabled={rows.length <= 1}
>
  Remove Row
</Button>
```

The buttonStyle prop offers multiple variants including primary, secondary, error, and subtle. These variants have predefined colors and hover states that match Payload's design system. The size prop controls button dimensions with options for small, medium, and large.

For primary actions like adding new rows, you can include icons:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
<Button
  buttonStyle="primary"
  size="medium"
  icon="plus"
  iconPosition="left"
  onClick={addRow}
>
  Add another row
</Button>
```

The icon prop accepts preset icon names like plus, edit, chevron, and x. The iconPosition prop controls whether icons appear before or after the button text. This built-in icon system means you don't need to import or configure icon libraries.

## Advanced Layout: DraggableSortable Lists

One of the most impressive components I discovered was DraggableSortable. It provides drag-and-drop reordering with proper spacing and visual feedback:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
<DraggableSortable
  className="array-field__draggable-rows"
  ids={rows.map((row) => row.id)}
  onDragEnd={({ moveFromIndex, moveToIndex }) => {
    setRows((prevRows) => {
      const newRows = [...prevRows]
      const [movedRow] = newRows.splice(moveFromIndex, 1)
      newRows.splice(moveToIndex, 0, movedRow)
      return newRows
    })
  }}
>
  {renderRows()}
</DraggableSortable>
```

The component requires an array of unique IDs matching your list items and an onDragEnd callback that receives the source and destination indices. The callback provides exactly what you need to reorder your state array. The component handles all the drag-and-drop interaction, visual feedback during dragging, and accessibility keyboard shortcuts for reordering.

The className prop is important here. Setting it to `array-field__draggable-rows` applies Payload's native array field spacing. This ensures proper gaps between items that match how native array fields look throughout the admin panel.

## Beyond Fields: Layout and Action Components

While field-level components handle data input, Payload's UI package includes a powerful layer of components for building the "chrome" of the admin panel: toolbars, menus, popups, and action controls. These components are what make custom features feel truly native.

I discovered these while building an "AI Tools" dropdown menu that needed to feel indistinguishable from Payload's core interface. The components I found handle everything from menu positioning to keyboard accessibility, providing the same polish you see in Payload's built-in features.

### Menu and Popup Components

The foundation for creating dropdown menus and triggered interfaces comes from three key components:

```typescript
// File: src/components/custom-views/AIToolsMenu.tsx
import { Popup, PopupButtonList } from '@payloadcms/ui'

const AIToolsMenu = () => {
  return (
    <Popup
      button={<button>AI Tools</button>}
      buttonType="custom"
      horizontalAlign="right"
      verticalAlign="bottom"
    >
      <PopupButtonList.ButtonGroup>
        <PopupButtonList.Button onClick={handleGenerateContent}>
          Generate Content
        </PopupButtonList.Button>
        <PopupButtonList.Button onClick={handleOptimizeSEO}>
          Optimize SEO
        </PopupButtonList.Button>
      </PopupButtonList.ButtonGroup>
    </Popup>
  )
}
```

The **Popup** component handles all the complexity of positioning, click-outside behavior, and accessibility. The `horizontalAlign` and `verticalAlign` props control where the menu appears relative to the trigger button. The `buttonType="custom"` allows you to use your own button component rather than Payload's default.

The **PopupButtonList** is a specialized pattern for action menus. It exports:
- `ButtonGroup`: Applies standard menu list styles including padding and separator lines
- `Button`: A native menu item with hover states, active selection states, and disabled states

These components create menus that are visually identical to Payload's built-in dropdowns. The hover states, spacing, and interaction patterns all match perfectly.

### Document Controls and Toolbars

When building custom document interfaces or admin views, you'll want access to the same controls that Payload uses for its standard edit views:

```typescript
import { DocumentControls, StickyToolbar, Gutter } from '@payloadcms/ui'

const CustomDocumentView = () => {
  return (
    <>
      <StickyToolbar>
        <Gutter>
          <DocumentControls
            // Controls like Save, Publish, Preview
          />
        </Gutter>
      </StickyToolbar>
      {/* Document content */}
    </>
  )
}
```

The **DocumentControls** component provides the standard "Save", "Publish", and "Preview" buttons. It handles all the state management and API calls for these actions, so you don't need to reimplement document saving logic.

The **StickyToolbar** utility makes toolbars stick to the top during scroll, matching how Payload's native document edit views behave. The **Gutter** component maintains consistent horizontal margins across the admin panel, ensuring your custom views align with Payload's standard spacing.

For side panel interfaces, Payload provides **DocumentDrawer** and **ListDrawer** components. These handle the complete drawer implementation including open/close animations, overlay behavior, and proper z-index stacking.

### Specialized UI Elements

Payload includes several micro-UI components that appear throughout the admin interface:

```typescript
import { Banner, Pill, toast } from '@payloadcms/ui'

// System-level alerts
<Banner type="warning">
  This document has unsaved changes
</Banner>

// Status indicators
<Pill>Draft</Pill>
<Pill>Published</Pill>

// Notifications
toast.success('Document saved successfully')
toast.error('Failed to save document')
```

The **Banner** component creates system-level information boxes with appropriate styling for warnings, errors, and info messages. The **Pill** component generates small status indicators identical to those Payload uses for document states.

The **toast** system handles success and error notifications. These are the same toast messages you see when saving documents or performing actions in Payload. Using these ensures your custom features provide feedback in a familiar way.

### Pre-wired Action Buttons

Payload also provides pre-wired button components for common document operations, all imported from `'@payloadcms/ui'`:

```typescript
import {
  PublishButton,
  SaveButton,
  SaveDraftButton,
  DeleteDocument,
  DuplicateDocument,
} from '@payloadcms/ui'
```

These components are pre-wired with all the logic for their respective actions. The **PublishButton** knows how to update document status and handle the API call. The **DeleteDocument** component includes the confirmation modal automatically. This saves you from reimplementing standard document operations in custom views.

The **ConfirmationModal** component is also available for any custom destructive actions that need user confirmation:

```typescript
import { ConfirmationModal } from '@payloadcms/ui'

const [showModal, setShowModal] = useState(false)

<ConfirmationModal
  isOpen={showModal}
  onConfirm={handleDelete}
  onCancel={() => setShowModal(false)}
  title="Delete this item?"
  description="This action cannot be undone."
/>
```

### Finding These Components

These layout and action components are less discoverable than field components because they're scattered across different directories:

```
node_modules/@payloadcms/ui/dist/
├── elements/
│   ├── Popup/          # Menu system
│   ├── Banner/         # Alert boxes
│   ├── Pill/           # Status indicators  
│   └── Toast/          # Notifications
└── admin/elements/     # Higher-level controls
    ├── DocumentControls/
    ├── PublishButton/
    ├── SaveButton/
    └── DeleteDocument/
```

The `elements` directory contains the building blocks, while `admin/elements` contains pre-wired document operation controls. Exploring both directories reveals the full set of components available for building admin interfaces.

## Admin Hooks: useConfig and useAuth

Beyond UI components, `@payloadcms/ui` exports two client-side hooks that are useful when building custom admin views and components that need context about the current user or Payload configuration.

```typescript
import { useConfig, useAuth } from '@payloadcms/ui'

const MyCustomComponent = () => {
  const { config } = useConfig()
  const { user } = useAuth()

  // config: full Payload config object
  // user: currently authenticated admin user
  console.log(config.serverURL)
  console.log(user?.email)

  return <div>...</div>
}
```

`useConfig` gives your component access to the full Payload configuration object on the client — useful for reading the server URL, collection slugs, or any other config values without hardcoding them. `useAuth` gives you the currently authenticated user object, including their roles and email. Both hooks require `'use client'` at the top of the file and must be used inside the Payload admin panel context.

In multi-tenant applications, the user object from `useAuth` drives which tenant's data a component should display. How user records relate to tenants in the schema shapes this logic directly — [why Payload CMS users shouldn't be tenant-scoped](/blog/payload-cms-users-not-tenant-scoped) explains the architectural decision behind it.

## Using Native CSS Classes

Payload includes extensive SCSS stylesheets for its components. Rather than writing custom CSS, you can leverage these existing classes to ensure your component matches the admin panel's design:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
return (
  <div className="array-field">
    <div className="array-field__header">
      <FieldLabel label="Bulk inventory batches" />
      <FieldDescription
        path="bulkRows"
        description="Add one row per product (variant) you produced."
      />
    </div>

    <DraggableSortable className="array-field__draggable-rows">
      {renderRows()}
    </DraggableSortable>

    <Button className="array-field__add-row">
      Add another row
    </Button>
  </div>
)
```

These CSS classes come from Payload's array field implementation. The `array-field` class provides the container styling. The `array-field__header` class handles spacing and layout for the field's title and description. The `array-field__draggable-rows` class adds proper gaps between items using CSS custom properties.

Looking at Payload's SCSS reveals how these classes work:

```scss
.array-field {
  display: flex;
  flex-direction: column;
  gap: calc(var(--base) / 2);

  &__draggable-rows {
    display: flex;
    flex-direction: column;
    gap: calc(var(--base) / 2);
  }

  &__add-row {
    align-self: flex-start;
    margin: 2px 0;
  }
}
```

The classes use CSS custom properties like `--base` that Payload defines globally. This means your component automatically adapts if Payload's spacing system changes. The gaps between items, the margins on buttons, and the overall layout all stay consistent with the rest of the admin panel.

## Fetching Cross-Collection Data

Custom fields often need data from other collections. My inventory component needed products, variants, and delivery dates. The key is handling this data fetching in a way that doesn't block the component from rendering. When those components also have to write derived data back through hooks, pair the UI work with the transaction-safe patterns from [How to Safely Manipulate Payload CMS Data in Hooks Without Hanging or Recursion](https://www.buildwithmatija.com/blog/payload-cms-hooks-safe-data-manipulation-postgresql):

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
const useOptions = <T,>(endpoint: string, map: (doc: any) => T) => {
  const [options, setOptions] = useState<T[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    let isMounted = true
    const controller = new AbortController()

    const load = async () => {
      setLoading(true)
      setError(null)
      try {
        const res = await fetch(endpoint, { signal: controller.signal })
        if (!res.ok) {
          throw new Error(`Failed to load options (${res.status})`)
        }
        const data = await res.json()
        const docs = Array.isArray(data?.docs)
          ? data.docs
          : Array.isArray(data?.data)
          ? data.data
          : []

        if (isMounted) {
          setOptions(docs.map(map))
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err.message : 'Unknown error')
        }
      } finally {
        if (isMounted) {
          setLoading(false)
        }
      }
    }

    void load()

    return () => {
      isMounted = false
      controller.abort()
    }
  }, [endpoint, map])

  return { options, loading, error }
}
```

This custom hook handles the complete lifecycle of fetching collection data. The AbortController ensures requests are cancelled if the component unmounts before they complete. The isMounted flag prevents state updates on unmounted components. The generic type parameter allows the hook to work with any data structure.

The hook handles both Payload's standard API format with a docs array and custom API routes that might return data in a different structure. This flexibility is important because you might need to use custom routes for complex queries or filtering.

Using the hook is straightforward:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
const {
  options: products,
  loading: productsLoading,
  error: productsError,
} = useOptions<ProductOption>(
  '/api/products?limit=500&depth=0',
  (doc) => ({
    id: doc.id,
    title: doc.title,
    hasVariants: Boolean(doc.hasVariants),
    inventoryAllocationMode: doc.inventoryAllocationMode,
    quantityUnit: doc.quantityUnit,
  })
)
```

The map function transforms raw API responses into exactly the shape your component needs. This keeps your component logic clean and separates data transformation from UI concerns.

## Handling Loading and Error States

With cross-collection data fetching, you need to handle loading and error states gracefully:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
const renderStatus = () => {
  if (productsLoading || deliveryDatesLoading) {
    return (
      <div className="field-type-text" style={{ marginTop: 8, color: '#666' }}>
        Loading reference data…
      </div>
    )
  }
  if (productsError) {
    return (
      <div className="field-type-text" style={{ color: '#b91c1c', marginTop: 8 }}>
        Failed to load products: {productsError}
      </div>
    )
  }
  if (deliveryDatesError) {
    return (
      <div className="field-type-text" style={{ color: '#b91c1c', marginTop: 8 }}>
        Failed to load delivery dates: {deliveryDatesError}
      </div>
    )
  }
  return null
}
```

The status rendering function provides feedback without blocking the entire component. Users see the form structure immediately, with loading indicators or error messages appearing inline. The `field-type-text` class ensures status messages use appropriate text styling that matches other field descriptions in Payload.

This approach is better than showing a full-page loading spinner or completely blocking the form. Users can see the overall structure and understand what data is loading. If one data source fails, the rest of the form remains functional.

## Complete Component Structure

Here's how all these pieces come together in the final component structure:

```typescript
// File: src/components/fields/InventoryBatchBulkTable.tsx
const InventoryBatchBulkTable: React.FC = () => {
  const { value, setValue } = useField<BulkRowPayload[]>({ path: 'bulkRows' })
  const [rows, setRows] = useState<BulkRowState[]>(/* initial state */)

  // Fetch reference data
  const { options: products } = useOptions(/* ... */)
  const { options: deliveryDates } = useOptions(/* ... */)

  // Sync local state with Payload form state
  useEffect(() => {
    const payloadRows = rows.map(mapRowToPayload)
    setValue(payloadRows)
  }, [rows, setValue])

  const updateRow = useCallback((rowId: string, updater) => {
    setRows((prev) => prev.map((row) =>
      row.id === rowId ? updater(row) : row
    ))
  }, [])

  const addRow = () => {
    setRows((prev) => [...prev, { id: `row-${Date.now()}`, /* ... */ }])
  }

  const duplicateRow = (rowId: string) => {
    setRows((prev) => {
      const row = prev.find((r) => r.id === rowId)
      return row ? [...prev, { ...row, id: `row-${Date.now()}` }] : prev
    })
  }

  const removeRow = (rowId: string) => {
    setRows((prev) => prev.length <= 1 ? prev : prev.filter((r) => r.id !== rowId))
  }

  return (
    <div className="array-field">
      <div className="array-field__header">
        <FieldLabel label="Bulk inventory batches" />
        <FieldDescription path="bulkRows" description="..." />
      </div>

      {renderStatus()}

      <DraggableSortable
        className="array-field__draggable-rows"
        ids={rows.map((row) => row.id)}
        onDragEnd={({ moveFromIndex, moveToIndex }) => {
          /* reorder logic */
        }}
      >
        {rows.map((row) => (
          <Collapsible key={row.id} header={/* ... */}>
            {/* Form fields using ReactSelect, TextInput, DatePickerField */}
            {/* Action buttons using Button component */}
          </Collapsible>
        ))}
      </DraggableSortable>

      <Button buttonStyle="primary" icon="plus" onClick={addRow}>
        Add another row
      </Button>
    </div>
  )
}
```

The component manages local state for immediate UI updates and syncs with Payload's form state through the useField hook. This pattern provides instant feedback to users while ensuring data is properly saved when the form submits. The native UI components handle all styling and behavior, leaving you to focus on business logic.

## Common Issues & Troubleshooting

### Module not found: Can't resolve '@payloadcms/ui/elements/X'

This is the most common error when using `@payloadcms/ui`, particularly when following older tutorials or upgrading from early Payload v3 betas. Path-specific imports from subpaths like `/elements/Button` are not valid in current Payload v3 releases.

```typescript
// ❌ Incorrect — causes "Module not found" error
import { Button } from '@payloadcms/ui/elements/Button'

// ✅ Correct — flat import from the main entry point
import { Button } from '@payloadcms/ui'
```

If you still get the error after fixing imports, delete `node_modules` and your lockfile, then reinstall with `pnpm install` (or your package manager's equivalent).

### TypeScript errors on @payloadcms/ui imports

If TypeScript reports missing type declarations, check that `moduleResolution` in your `tsconfig.json` is set to `"bundler"` or a compatible modern resolution mode. The package ships its own types from the flat entry point — path-specific type imports won't resolve correctly.

### useField returns undefined

Two common causes: the component is missing `'use client'` at the top of the file, or it's being used outside of a Payload field configuration. The `useField` hook relies on context provided by Payload's form system and only works inside the admin panel when registered as a custom field component. Double-check that the `path` prop matches a field path in the collection config.

### DraggableSortable renders without drag handles

This is almost always a CSS issue. The drag handles rely on Payload's admin stylesheet being present. Verify that no global CSS resets are overriding the handle styles, and that the `DraggableSortable` component receives the required `ids` prop — missing IDs will silently break the sorting behavior.

## The Results

Building custom fields with Payload's native UI components creates interfaces that feel completely integrated with the admin panel. Users can't tell where Payload ends and custom components begin. The consistent styling, familiar interaction patterns, and proper spacing make the experience seamless.

You now have a complete workflow for building custom Payload fields: discover available components by exploring node_modules and reading TypeScript definitions, import everything from the flat `'@payloadcms/ui'` entry point, use the core UI components for forms, layouts, and interactions, apply Payload's CSS classes for consistent design, and debug issues with the troubleshooting section above. For a full reference of every component in `@payloadcms/ui`, the [Payload CMS Admin UI Components: Complete Glossary](/blog/payload-admin-ui-components-glossary-v3-6) is worth bookmarking.

For teams building multi-tenant Payload applications, these same components are the foundation for tenant-specific admin views. [Handling search across a shared multi-tenant index in Payload](/blog/payload-cms-multi-tenant-search-shared-index) shows how the data architecture fits together once the UI layer is established.

If you have questions about specific components or run into challenges implementing custom fields, let me know in the comments. And subscribe for more practical guides on working with Payload CMS and modern web development tools.

If you're building custom Payload CMS interfaces and need a senior Payload specialist to work with your team or review your admin setup, [I work with a small number of clients at a time](/payload-cms-developer).

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Step-by-step guide to creating custom Payload CMS v3 admin interfaces with @payloadcms/ui components, including forms, layouts, data fetching, more",
  "responses": [
    {
      "question": "What does the article \"Building Custom Admin UI in Payload CMS v3: A Complete Guide to @payloadcms/ui Components\" cover?",
      "answer": "Step-by-step guide to creating custom Payload CMS v3 admin interfaces with @payloadcms/ui components, including forms, layouts, data fetching, more"
    }
  ]
}
```