Key Value Field
A form component for managing a dynamic list of key-value pairs with add and delete controls.
Made by eblairmckeeWhen 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.
Installation
Usage
import { KeyValueField, type KeyValuePair } from '@/registry/components/key-value-field';const [pairs, setPairs] = useState<KeyValuePair[]>([]);
<KeyValueField
label="Labels"
onChange={setPairs}
value={pairs}
/>Examples
Default
Creatable Combobox
Form Integration
Use with React Hook Form via a controlled FormField:
import { KeyValueField, type KeyValuePair } from '@/registry/components/key-value-field';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/registry/components/form';
<FormField
control={form.control}
name="labels"
render={({ field }) => (
<FormItem>
<FormLabel>Labels</FormLabel>
<FormControl>
<KeyValueField
onChange={field.onChange}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>Custom Placeholders
<KeyValueField
keyFieldProps={{ placeholder: 'Header name' }}
label="HTTP Headers"
onChange={setHeaders}
valueFieldProps={{ placeholder: 'Header value' }}
value={headers}
/>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.
<KeyValueField
keyFieldProps={{
mode: 'combobox',
options: [
{ label: 'environment', value: 'environment' },
{ label: 'region', value: 'region' },
],
creatable: true,
placeholder: 'Select or create key',
}}
label="Labels"
onChange={setPairs}
value={pairs}
/>Custom Add Button Label
<KeyValueField
addButtonLabel="Add variable"
label="Environment Variables"
onChange={setEnvVars}
value={envVars}
/>Without Add Button
Use showAddButton={false} when you want to control the list externally or render the add control elsewhere.
<KeyValueField
label="Labels"
onChange={setPairs}
showAddButton={false}
value={pairs}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | KeyValuePair[] | [] | Controlled list of key-value pairs |
onChange | (value: KeyValuePair[]) => void | — | Called with the updated list on any change |
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
type KeyValuePair = {
key: string;
value: string;
};KeyValueFieldConfig type
A discriminated union that configures each key/value field. Set mode to choose between a plain input and a combobox.
// 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 adding a row, 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.
Companion Utilities
The input-utils library provides utilities that pair well with KeyValueField.
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.
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { keyValuePairsSchema } from '@/registry/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.
import { getKeyValueDiff } from '@/registry/lib/input-utils';
const diff = getKeyValueDiff(initialPairs, currentPairs);
// diff.created → { newKey: 'value' }
// diff.updated → { existingKey: 'newValue' }
// diff.removed → ['deletedKey']useMemoizedArray
Preserves object references for unchanged items across renders, reducing unnecessary child re-renders when the parent rebuilds the array.
import { useMemoizedArray } from '@/registry/lib/input-utils';
const stablePairs = useMemoizedArray(
pairs,
(a, b) => a.key === b.key && a.value === b.value,
);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.
import { useUndoRemoval } from '@/registry/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 waitinguseInputListFocus
Manages focus after add/remove operations in dynamic input lists. Already integrated into KeyValueField — use directly when building your own dynamic list components.
import { useInputListFocus } from '@/registry/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 rowRows 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.
import { findDuplicateIndices } from '@/registry/lib/input-utils';
const duplicates = findDuplicateIndices(items, (item) => item.key);
// duplicates → Set { 1, 3 } if items[1] and items[3] share a keyTesting
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 |
Built by malinskibeniamin. The source code is available on GitHub.