---
title: Key Value Field
description: A form component for managing a dynamic list of key-value pairs with add and delete controls.
icon: list
author:
  name: eblairmckee
  url: https://github.com/eblairmckee
mode: wide
---

### Installation

```package-install
npx shadcn@latest add @redpanda/key-value-field
```

<ComponentPreview name="key-value-field-demo" description="A key-value field with pre-populated pairs." />

## When to use

Use `KeyValueField` when users need to define arbitrary metadata as key-value pairs — for example, resource labels, environment variables, or HTTP headers. It manages a dynamic list where items can be freely added, edited, and removed.

## Usage

```tsx
import { KeyValueField } from '@/components/redpanda-ui/components/key-value-field';
import { createKeyValueRows, keyValuePairs } from '@/components/redpanda-ui/components/key-value-field/rows';
```

```tsx
const [rows, setRows] = useState(() => createKeyValueRows([{ key: 'environment', value: 'production' }]));

<KeyValueField label="Labels" onChange={setRows} value={rows} />;

// Validate or submit only the domain metadata, never the UI IDs.
const pairs = keyValuePairs(rows);
```

## Examples

### Default

<ComponentPreview name="key-value-field-demo" description="A key-value field with pre-populated pairs." />

### Creatable Combobox

<ComponentPreview
  name="key-value-field-creatable-demo"
  description="Key-value field with creatable comboboxes for both keys and values."
/>

### Form Integration

Keep domain pairs in the form. Use `useFieldArray`'s `fields` IDs for the controlled UI rows, paired with the current controller values. Translate `describeKeyValueRowChange` actions as follows:

- `append`: append the new **pair** with `append(pair, { shouldFocus: false })`.
- `remove`: remove its position with `remove(index)`.
- `edit`: pass the projected pairs to the existing controller's `field.onChange`.

Do not call `useFieldArray.update` or replace the whole field array for each keystroke: those operations can replace the row IDs and remount focused controls. RHF owns IDs; they must not enter schema validation or submitted metadata. The working example below uses the pure `keyValuePairsSchema` and displays every row error:

<ComponentPreview
  name="key-value-field-validate-demo"
  description="Pure form values with RHF-owned row identities and validation."
/>

### Custom Placeholders

```tsx
<KeyValueField
  keyFieldProps={{ placeholder: 'Header name' }}
  label="HTTP Headers"
  onChange={setHeaderRows}
  valueFieldProps={{ placeholder: 'Header value' }}
  value={headerRows}
/>
```

### Combobox Fields

Use `mode: 'combobox'` on `keyFieldProps` or `valueFieldProps` to render a combobox with predefined options instead of a plain input. All `ComboboxProps` (such as `creatable`, `options`, and `onCreateOption`) are available.

```tsx
<KeyValueField
  keyFieldProps={{
    mode: 'combobox',
    options: [
      { label: 'environment', value: 'environment' },
      { label: 'region', value: 'region' },
    ],
    creatable: true,
    placeholder: 'Select or create key',
  }}
  label="Labels"
  onChange={setRows}
  value={rows}
/>
```

### Custom Add Button Label

```tsx
<KeyValueField
  addButtonLabel="Add variable"
  label="Environment Variables"
  onChange={setEnvironmentRows}
  value={environmentRows}
/>
```

### Without Add Button

Use `showAddButton={false}` when you want to control the list externally or render the add control elsewhere.

```tsx
<KeyValueField label="Labels" onChange={setRows} showAddButton={false} value={rows} />
```

## Props

| Prop              | Type                             | Default                    | Description                                                         |
| ----------------- | -------------------------------- | -------------------------- | ------------------------------------------------------------------- |
| `value`           | `readonly KeyValueRow[]`         | `[]`                       | Controlled UI rows with stable IDs                                  |
| `onChange`        | `(value: KeyValueRow[]) => void` | —                          | Called with updated UI rows; retained rows keep their IDs           |
| `label`           | `ReactNode`                      | —                          | Label rendered above the field                                      |
| `description`     | `ReactNode`                      | —                          | Helper text rendered below the label                                |
| `addButtonLabel`  | `string`                         | `'Add'`                    | Label for the add button                                            |
| `keyFieldProps`   | `KeyValueFieldConfig`            | `{ placeholder: 'Key' }`   | Props for each key field — see below                                |
| `valueFieldProps` | `KeyValueFieldConfig`            | `{ placeholder: 'Value' }` | Props for each value field — see below                              |
| `showAddButton`   | `boolean`                        | `true`                     | Whether to render the add button                                    |
| `disabled`        | `boolean`                        | —                          | Disables all inputs and buttons                                     |
| `maxItems`        | `number`                         | —                          | Maximum number of pairs allowed — hides the add button when reached |
| `testId`          | `string`                         | —                          | Base `data-testid` — sub-IDs are derived automatically              |

### `KeyValuePair` type

```ts
type KeyValuePair = {
  key: string;
  value: string;
};
```

### `KeyValueRow` identity and helpers

```ts
type KeyValueRow = {
  readonly id: string;
  pair: KeyValuePair;
};
```

`createKeyValueRows(pairs)` allocates IDs once when creating a new controlled document. Call it in a state initializer or replacement event, not during a render map. New rows added by the editor receive an ID once in the add event. Keep each ID when editing, cloning, reordering, removing, or undoing removal; never use the editable key/value as identity.

`keyValuePairs(rows)` projects only `{ key, value }` for validation, diffing, or submission. The existing `KeyValuePair` domain type and `keyValuePairsSchema` remain unchanged. A genuinely new document gets fresh IDs, deliberately resetting old controls. Replacing values while retaining IDs deliberately preserves those controls. Duplicate or empty IDs produce a visible error instead of rendering ambiguous rows.

The previous `value: KeyValuePair[]` / pair-only `onChange` contract is retired. Existing callers must keep `KeyValueRow[]` UI state and explicitly project domain pairs. Blank or duplicate pairs are valid distinct rows because identity is separate from their contents.

### `KeyValueFieldConfig` type

A discriminated union that configures each key/value field. Set `mode` to choose between a plain input and a combobox.

```ts
// Plain input (default)
type InputFieldConfig = { mode?: 'input' } & Omit<InputProps, 'value' | 'onChange' | 'disabled' | 'aria-invalid'>;

// Combobox with options
type ComboboxFieldConfig = { mode: 'combobox' } & Omit<ComboboxProps, 'value' | 'onChange' | 'disabled'>;

type KeyValueFieldConfig = InputFieldConfig | ComboboxFieldConfig;
```

## Validation

The component applies inline validation styling automatically:

- If a **value** is filled but the **key** is empty, the key input is marked `aria-invalid`.
- If a **key** is filled but the **value** is empty, the value input is marked `aria-invalid`.
- If two or more rows share the same **key**, all duplicate key inputs are marked `aria-invalid`.

For full form validation (required fields, custom rules), integrate with a form library such as React Hook Form and render `<FormMessage />` alongside the field.

## Focus management

After an added row is accepted by the controlled owner, focus automatically moves to the first input in the new row. After deleting a row, focus moves to the next row (or the previous row if the last row was deleted). This is powered by the `useInputListFocus` hook from `input-utils`. Ignored or throwing updates do not move focus; thrown update errors are visible. ID-preserving edits and reorders retain mounted inputs, local combobox searches, refs, and native validity state.

## Companion Utilities

The `input-utils` library provides utilities that pair well with `KeyValueField`.

```package-install
npx shadcn@latest add @redpanda/input-utils
```

### `keyValuePairsSchema`

Returns a Zod schema that validates key-value pairs — covering empty keys, duplicate keys, character patterns, length limits, and item count. Use with React Hook Form + `zodResolver` for form-level validation.

<ComponentPreview
  name="key-value-field-validate-demo"
  description="Form validation with keyValuePairsSchema and React Hook Form."
/>

```tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { keyValuePairsSchema } from '@/lib/input-utils';

const FormSchema = z.object({
  labels: keyValuePairsSchema({ maxItems: 10, maxKeyLength: 128 }),
});

const form = useForm<z.infer<typeof FormSchema>>({
  resolver: zodResolver(FormSchema),
  defaultValues: { labels: [] },
});
```

The schema adds a root-level Zod issue (displayed by `FormMessage`) and per-field issues at `[index, field]` paths for row-level error access.

#### `KeyValuePairsSchemaOptions`

| Option           | Type     | Description                           |
| ---------------- | -------- | ------------------------------------- |
| `maxItems`       | `number` | Maximum number of pairs allowed       |
| `maxKeyLength`   | `number` | Maximum character length for keys     |
| `maxValueLength` | `number` | Maximum character length for values   |
| `allowedPattern` | `RegExp` | Regex that keys and values must match |

### `getKeyValueDiff`

Compares initial and current pairs to produce a delta of created, updated, and removed entries — useful when submitting only changes to an API.

<ComponentPreview name="key-value-field-diff-demo" description="Edit pairs to see the diff from the initial state." />

```tsx
import { getKeyValueDiff } from '@/lib/input-utils';

const diff = getKeyValueDiff(initialPairs, currentPairs);
// diff.created  → { newKey: 'value' }
// diff.updated  → { existingKey: 'newValue' }
// diff.removed  → ['deletedKey']
```

### `useUndoRemoval`

Soft-delete pattern with timed auto-confirm and undo support. Call `markForRemoval` with a string identifier and a callback that fires after the timeout unless `undoRemoval` is called first.

<ComponentPreview
  name="key-value-field-undo-demo"
  description="Remove a tag to see the undo prompt before auto-confirmation."
/>

```tsx
import { useUndoRemoval } from '@/lib/input-utils';

const { markForRemoval, undoRemoval, isPending } = useUndoRemoval(5000);

markForRemoval('item-id', () => {
  // Actually remove the item after 5 seconds
});

// Cancel within the timeout
undoRemoval('item-id');

// Check pending state for rendering
isPending('item-id'); // true while waiting
```

### `useInputListFocus`

Manages focus after add/remove operations in dynamic input lists. Already integrated into `KeyValueField` — use directly when building your own dynamic list components.

```tsx
import { useInputListFocus } from '@/lib/input-utils';

const containerRef = useRef<HTMLDivElement>(null);
const { onAdd, onRemove } = useInputListFocus(containerRef);

// After appending a row:
onAdd(); // focuses the first input in the new row

// After deleting a row:
onRemove(deletedIndex); // focuses the next logical row
```

Rows must have a `data-row` attribute for the focus selector to work.

### `findDuplicateIndices`

Generic utility that returns a `Set<number>` of indices whose extracted key appears more than once. Already used internally by `KeyValueField` for duplicate detection.

```tsx
import { findDuplicateIndices } from '@/lib/input-utils';

const duplicates = findDuplicateIndices(items, (item) => item.key);
// duplicates → Set { 1, 3 } if items[1] and items[3] share a key
```

## Testing

When `testId` is provided, child elements receive derived IDs:

| Element                    | `data-testid`     |
| -------------------------- | ----------------- |
| Root container             | `testId`          |
| Key input at index `n`     | `testId-key-n`    |
| Value input at index `n`   | `testId-value-n`  |
| Delete button at index `n` | `testId-delete-n` |
| Add button                 | `testId-add`      |
