Lookup Field
Reference field for linking to other objects/records
The Lookup Field component provides a reference field for creating relationships between objects and records. It supports dynamic data loading from a DataSource with debounced search, loading/error/empty states, keyboard navigation, and optional quick-create entry.
Basic Usage
Basic Lookup
Multiple References
Multi Select Lookup
Field Schema
A lookup field is authored as LookupFieldMetadata (@object-ui/types), which is the
source of truth for the key set: it extends BaseFieldMetadata with the reference
target, the display and description fields, an optional static option list, and the
Record Picker's column and paging configuration. Static options are SelectOptionMetadata,
and picker columns are LookupColumnDef — both exported, both checked here.
import type { LookupFieldMetadata } from '@object-ui/types';
const accountId: LookupFieldMetadata = {
type: 'lookup',
name: 'account_id',
label: 'Account',
placeholder: 'Search accounts…',
required: true,
reference: 'accounts',
reference_field: 'name',
descriptionField: 'industry',
multiple: false,
searchable: true,
allowCreate: true,
// Record Picker dialog (Enterprise): columns accept a field name or a descriptor.
lookupColumns: ['name', { field: 'industry', label: 'Industry', width: '160px' }],
lookupPageSize: 10,
lookupFilters: [{ field: 'active', operator: 'eq', value: true }],
};The target object is reference — the spelling @objectstack/spec's FieldSchema
declares, and the only one the lookup and user widgets and their read cells read.
objectui#11070 retired
objectui's snake_case twin reference_to: the spec refuses it by name, and nothing
reads it. A stored object definition that still spells it is folded onto reference
when it is served through the ObjectStack adapter; a definition handed to the widget
any other way names no target — rename the key to reference.
The quick-create switch and the Record Picker's configuration are allowCreate,
lookupColumns and lookupPageSize, the spellings FieldSchema declares and the only
ones the widget reads.
objectui#12157 retired
objectui's snake_case twins allow_create, lookup_columns and lookup_page_size,
with no alias: the spec refuses each of them, and nothing reads them. A stored object
definition that still spells one is folded onto the declared key when it is served
through the ObjectStack adapter or the console's metadata store; a definition handed
to the widget any other way loses the setting — rename the key.
The record field a pick commits as the value, id unless told otherwise, is picker
configuration rather than object metadata. FieldSchema refuses an idField by
name, so a lookup field definition cannot carry it, and LookupFieldMetadata does
not declare it. A host sets it where it builds the picker: on a form entry
(FormField.idField), on an action parameter, or on a master_detail inline grid
column. A reference stored by machine name sets it to name.
When no data source is available the field falls back to a static option list:
import type { LookupFieldMetadata } from '@object-ui/types';
const priority: LookupFieldMetadata = {
type: 'lookup',
name: 'priority',
label: 'Priority',
reference: 'priorities',
options: [
{ label: 'High', value: 'high' },
{ label: 'Normal', value: 'normal' },
],
};A static option may also carry a description — secondary text the picker's
typeahead searches alongside the label. Both faces declare it:
SelectOptionMetadata in @object-ui/types, and @objectstack/spec as
SelectOptionSchema.description, by the option-A ruling on
objectui#6140 /
objectui#6153 — so an
option carrying it is accepted in an authored object document. The spec's option
is still strict: a key it does not declare, such as icon (which
SelectOptionMetadata carries only for objectui's own renderers), is refused
by name (unrecognized_keys), and since FieldSchema routes a field's
options through that schema, it fails the whole field.
select-option-spec-extension-7014.test.ts (@object-ui/types) re-derives both
verdicts against the installed spec. The
dataSource a host injects and the onCreateNew callback it passes are widget props,
not metadata.
A dependent lookup scopes its candidates by a sibling field's value, declared
with dependsOn — the same BaseFieldMetadata member the select widgets gate on
(objectui#6153), in the
spec's field-level shape: an array of controlling field names, or { field, param }
entries when the remote filter parameter differs from the local field name. While
any controlling value is empty the trigger is gated ("Select account first"); once
set, every candidate query — the typeahead popover, the Record Picker and the
people picker — carries the chain as a hard $filter no user input can override.
import type { LookupFieldMetadata } from '@object-ui/types';
const contact: LookupFieldMetadata = {
type: 'lookup',
name: 'contact',
label: 'Contact',
reference: 'contacts',
// Filter `contacts` by `account_id` equal to the form's current `account`.
dependsOn: [{ field: 'account', param: 'account_id' }],
};dependsOn is the only spelling this widget reads.
objectui#7357 retired
objectui's snake_case twin depends_on: it was never a @objectstack/spec key
(the object contract refuses it by name, so a document carrying it is rejected
at publish), and the widget no longer reads it. Metadata that still spells it
that way does not gate and does not scope the picker — rename it to dependsOn.
The value being edited, and the className / disabled a host supplies, are not
metadata keys — they are runtime widget props. See Field Widget Props.
Dynamic Data Source
When a DataSource is available (via SchemaRendererContext, explicit prop, or field config), the Lookup popup automatically fetches records from the referenced object:
// Automatic — DataSource from SchemaRendererContext
// (works out-of-the-box in ObjectForm, DrawerForm, etc.)
{
type: 'lookup',
name: 'customer',
label: 'Customer',
reference: 'customers',
reference_field: 'name', // Display field (default: 'name')
descriptionField: 'industry', // Optional secondary field
}The popup will:
- Fetch records via
dataSource.find(reference, { $top: 50 })on open - Send
$searchqueries with 300ms debounce as the user types - Show loading spinner, error state with retry, and empty state
- Display "Showing X of Y" when more records exist than the page size
- Show a "Show All Results" button (inside the popover) to open the full Record Picker dialog when total exceeds page size
Browse All Button
Every Lookup field with a dataSource always renders a "Browse All" button (table icon) next to the quick-select trigger. This button opens the full RecordPickerDialog directly, regardless of dataset size — ensuring enterprise features like multi-column tables, sort/filter bar, and cell renderers are always discoverable.
- Always visible when
dataSourceis configured - Opens the Record Picker dialog without needing to open the popover first
- Keyboard accessible and screen-reader friendly (
aria-label="Browse all records")
Record Picker Dialog (Enterprise)
The full RecordPickerDialog can be opened in two ways:
- "Browse All" button (table icon) — always visible next to the quick-select trigger
- "Show All Results" link inside the popover — shown when total records exceed the page size
// Configure the Record Picker with lookupColumns
{
type: 'lookup',
name: 'order',
label: 'Order',
reference: 'orders',
reference_field: 'order_number',
descriptionField: 'customer_name',
lookupColumns: [
{ field: 'order_number', label: 'Order #' },
{ field: 'customer_name', label: 'Customer' },
{ field: 'total_amount', label: 'Amount' },
{ field: 'status', label: 'Status' },
],
lookupPageSize: 15,
}The Record Picker dialog provides:
- Multi-column table with configurable columns via
lookupColumns - Search with debounced server-side querying
- Column sorting via clickable headers (sends
$orderbyto DataSource) - Pagination with page-by-page navigation
- Keyboard navigation — Arrow keys to move between rows, Enter/Space to select
- Single/Multi-select with visual check indicators and confirmation flow
- Responsive layout — Mobile-friendly width (95vw on small screens)
- Loading, error, and empty states
- Auto-inferred columns from
reference_fieldwhenlookupColumnsis not set
Lookup vs Master-Detail
- Lookup: Standard reference field, can be deleted independently
- Master-Detail: Parent-child relationship, deleting parent deletes children
Cell Renderer
In tables/grids, lookup values display the referenced record name:
import { LookupCellRenderer } from '@object-ui/fields';
// Single value: Display name/label
// Multiple values: Multiple chips/badgesUse Cases
- Assignments: Assign tasks to users
- Relationships: Link related records
- Categories: Reference to category objects
- Parent Records: Master-detail relationships
- Team Members: Multi-user references
Features
- Two-Level Interaction: Popover typeahead (Level 1) + full Record Picker dialog (Level 2)
- Record Picker Dialog: Enterprise-grade table with multi-column, pagination, search, sorting
- Inline Popover: Level 1 opens as anchored dropdown (non-modal) for fast typeahead
- Column Sorting: Clickable column headers with
$orderbyserver-side sort - Dynamic DataSource Loading: Automatically fetches records from referenced objects
- Search: Debounced type-ahead search with
$searchparameter - Multi-Select: Support for multiple references with confirmation flow
- Keyboard Navigation: Arrow keys to navigate rows, Enter to select in both levels
- Responsive: Mobile-friendly width, adapts to screen size
- Loading/Error/Empty States: Friendly feedback for all states
- Secondary Field Display: Show description/subtitle per option
- Quick-Create Entry: Optional "Create new" button when no results
- Configurable Columns:
lookupColumnsfor multi-column picker display - Base Filters:
lookupFiltersto restrict selectable records - Pagination: Page-by-page navigation in Record Picker dialog
- Backward Compatible: Falls back to static options when no DataSource