Patterns
Key Value Editor
A list of key and value pairs that the user can edit, with validation, masked values and import.
Patterns
A list of key and value pairs that the user can edit, with validation, masked values and import.
key=value lines into a key field.import { Card, CardContent, CardDescription, CardHeader, CardTitle, KeyValueEditor, rowsFromPairs } from 'ferry-ui'
// `rowsFromPairs` gives each pair a stable row id.
const LABELS = rowsFromPairs([
{ key: 'team', value: 'growth' },
{ key: 'cost-center', value: 'CC-4102' },
{ key: 'owner', value: 'maya@example.com' },
])
export default function KeyValueEditorHero() {
return (
<Card className="mx-auto w-full max-w-xl">
<CardHeader>
<div>
<CardTitle>Labels</CardTitle>
<CardDescription>The labels of the project “Billing portal”.</CardDescription>
</div>
</CardHeader>
<CardContent>
<KeyValueEditor
defaultValue={LABELS}
addLabel="Add label"
itemNoun={{ one: 'label', other: 'labels' }}
listLabel="Project labels"
/>
</CardContent>
</Card>
)
}TooltipProvider above the editor.Import the component and the hook. The hook holds the rows for a controlled editor.
import { KeyValueEditor, useKeyValueRows } from 'ferry-ui'
const state = useKeyValueRows(pairs)
<KeyValueEditor value={state.rows} onValueChange={state.setRows} errors={state.errors} />| Type | Shape | Use |
|---|---|---|
KeyValuePair | { key, value, secret? } | The data that your app stores. |
KeyValueRow | A pair and a stable id. | The data that the editor shows. |
useKeyValueRows gives pairs for the request, errors and valid for the validation, and dirty for a Save Bar. The hook reads its argument on the first render only. After a load or a save, call reset.
key=value lines into a key field.import * as React from 'react'
import { Card, CardContent, KeyValueEditor, SaveBar, toast, useKeyValueRows, validateIdentifierKey, type KeyValuePair } from 'ferry-ui'
const INITIAL: KeyValuePair[] = [
{ key: 'team', value: 'growth' },
{ key: 'cost-center', value: 'CC-4102' },
]
// Module level: the options of the hook stay the same between renders.
const OPTIONS = { validateKey: validateIdentifierKey }
export default function KeyValueEditorSaveBar() {
const [saved, setSaved] = React.useState(INITIAL)
const [saving, setSaving] = React.useState(false)
const labels = useKeyValueRows(saved, OPTIONS)
function save() {
const pairs = labels.pairs
setSaving(true)
// Stands for a request to the server.
window.setTimeout(() => {
setSaved(pairs)
// The hook reads its argument on the first render only: give it the new saved state.
labels.reset(pairs)
setSaving(false)
toast.success('Labels saved')
}, 1000)
}
return (
<Card className="mx-auto w-full max-w-xl">
<CardContent>
{/* The same validator as the hook: pasted lines and imported lines follow the same rule. */}
<KeyValueEditor
value={labels.rows}
onValueChange={labels.setRows}
errors={labels.errors}
validateKey={validateIdentifierKey}
disabled={saving}
addLabel="Add label"
emptyMessage="No labels yet."
/>
</CardContent>
<SaveBar
dirty={labels.dirty}
invalid={!labels.valid}
saving={saving}
hint={`Labels: ${labels.pairs.length}`}
onReset={() => labels.reset(saved)}
onSave={save}
/>
</Card>
)
}The editor shows an error for a value with no key and for a duplicate key. validateKey adds a check of the key format. validateIdentifierKey is a validator for the names of variables.
Give the same validator to the hook and to the editor.
Key is required
Duplicate key MAX_RETRIES
Use letters, digits, _, . or - (not starting with a digit)
key=value lines into a variable field.import { KeyValueEditor, rowsFromPairs, validateIdentifierKey } from 'ferry-ui'
const VARIABLES = rowsFromPairs([
{ key: 'MAX_RETRIES', value: '5' },
{ key: '', value: 'eu-west' },
{ key: 'MAX_RETRIES', value: '3' },
{ key: '2FA_REQUIRED', value: 'true' },
])
export default function KeyValueEditorValidation() {
return (
<KeyValueEditor
className="mx-auto w-full max-w-xl"
defaultValue={VARIABLES}
validateKey={validateIdentifierKey}
keyLabel="Variable"
keyPlaceholder="NAME"
addLabel="Add variable"
itemNoun={{ one: 'variable', other: 'variables' }}
/>
)
}A row with secret: true shows a mask and a reveal button. maskValues masks each row. revealAll shows each value and hides the reveal buttons.
key=value lines into a header field.import * as React from 'react'
import { KeyValueEditor, Label, Switch, rowsFromPairs } from 'ferry-ui'
const HEADERS = rowsFromPairs([
{ key: 'Accept', value: 'application/json' },
{ key: 'Authorization', value: 'Bearer tok_demo_8f2a91c4', secret: true },
{ key: 'X-Signature', value: 'sig_demo_31b9e0a4c2', secret: true },
])
export default function KeyValueEditorSecret() {
const [revealAll, setRevealAll] = React.useState(false)
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-4">
<div className="flex items-center gap-2">
<Switch id="reveal-all-values" size="sm" checked={revealAll} onCheckedChange={setRevealAll} />
<Label htmlFor="reveal-all-values" className="font-normal">
Reveal all values
</Label>
</div>
<KeyValueEditor
defaultValue={HEADERS}
revealAll={revealAll}
keyLabel="Header"
keyPlaceholder="X-Header-Name"
addLabel="Add header"
itemNoun={{ one: 'header', other: 'headers' }}
/>
</div>
)
}keyLabel, addLabel, importLabel and itemNoun set the visible texts. emptyMessage shows while the list has no row. To translate the other texts, pass labels.
key=value lines into a tag field.import { KeyValueEditor } from 'ferry-ui'
export default function KeyValueEditorTexts() {
return (
<KeyValueEditor
className="mx-auto w-full max-w-xl"
keyLabel="Tag"
keyPlaceholder="tag"
addLabel="Add tag"
importLabel="Import tags"
itemNoun={{ one: 'tag', other: 'tags' }}
listLabel="Invoice tags"
emptyMessage="No tags yet. Add one, or import a list."
/>
)
}readOnly removes the add, remove and import buttons. disabled locks each field and each button but keeps the layout. Use disabled while a save is in progress.
import { KeyValueEditor, rowsFromPairs } from 'ferry-ui'
const HEADERS = rowsFromPairs([
{ key: 'Accept', value: 'application/json' },
{ key: 'Authorization', value: 'Bearer tok_demo_8f2a91c4', secret: true },
{ key: 'X-Api-Version', value: '2026-01' },
])
export default function KeyValueEditorReadOnly() {
return (
<KeyValueEditor
className="mx-auto w-full max-w-xl"
readOnly
defaultValue={HEADERS}
keyLabel="Header"
listLabel="Webhook headers"
/>
)
}The user can paste key=value lines into a key field, or open the Import dialog. A key that exists gets the new value. A new key goes to the end of the list. To hide the Import button, set allowImport to false.
team=growth
owner="Maya Chen"
# a commentrole="alert".KeyValueEditor also accepts each attribute of the <div> element.
| Prop | Type | Default |
|---|---|---|
value | readonly KeyValueRow[] | - |
Rows to show (controlled). Pair it with | ||
defaultValue | readonly KeyValueRow[] | - |
Initial rows when uncontrolled. Ignored when | ||
onValueChange | ((rows: KeyValueRow[]) => void) | - |
Called with the next rows on every edit, add, remove, paste and import. | ||
errors | ReadonlyMap<string, string> | - |
Validation messages keyed by row id (e.g. | ||
validateKey | KeyValidator | - |
Extra format check for keys. Typed keys only need to be non-empty and unique by default. It also decides which pasted / imported lines are accepted (default for those: any key without whitespace). Use | ||
readOnly | boolean | false |
Show the rows without editing: no add / remove / import, inputs read-only (reveal toggles stay). | ||
disabled | boolean | false |
Lock every field and button, e.g. while the rows are being saved. Unlike | ||
maskValues | boolean | false |
Treat every row as secret: values show a fixed-length mask with a per-row reveal toggle. Rows with | ||
revealAll | boolean | false |
Show every masked value in clear and hide the per-row reveal toggles (bind it to a "Reveal all" switch). | ||
keyLabel | string | Key |
Column caption and accessible name prefix of key fields. Default "Key". | ||
valueLabel | string | Value |
Column caption and accessible name prefix of value fields. Default "Value". | ||
keyPlaceholder | string | key |
Placeholder of key fields. Default "key". | ||
valuePlaceholder | string | value |
Placeholder of value fields. Default "value". | ||
addLabel | ReactNode | Add row |
Text of the add button. Default "Add row". | ||
allowImport | boolean | true |
Show the bulk import button and dialog (paste or load | ||
importLabel | ReactNode | Import |
Text of the import button. Default "Import". | ||
importAccept | string | .txt,.env,text/plain |
File types offered by the "Choose file…" picker of the import dialog: the native | ||
itemNoun | KeyValueItemNoun | DEFAULT_NOUN |
Noun used in generated copy (import dialog, fallback row names). Default entry / entries. | ||
listLabel | string | Key/value pairs |
Accessible name of the row list. Default "Key/value pairs". | ||
emptyMessage | ReactNode | - |
Shown instead of the list when there are no rows (e.g. "No headers yet."). | ||
labels | Partial<KeyValueEditorLabels> | - |
Overrides for the other built-in texts (accessible names, tooltips, paste tip, import dialog), e.g. to translate them. Unset entries keep their English default. | ||
useKeyValueRows(initial, options?) returns an object. Keep options stable between renders.
| Name | Type | Role |
|---|---|---|
initial | KeyValuePair[] | Argument. The saved pairs. |
options | ValidateRowsOptions | Argument. It holds validateKey and messages. |
rows | KeyValueRow[] | The rows. Pass them to value. |
setRows | function | The setter. Pass it to onValueChange. |
reset | (pairs) => void | Replaces the saved pairs and the rows. |
pairs | KeyValuePair[] | The rows as pairs, with no blank row. |
errors | Map | The messages. The key of the map is the row id. |
valid | boolean | true when there is no error. |
dirty | boolean | true when pairs is different from the saved pairs. |
| Name | Role |
|---|---|
rowsFromPairs(pairs) | Makes rows with new ids from pairs. |
pairsFromRows(rows) | Makes pairs from rows. It removes blank rows and trims the keys. |
validateRows(rows, options?) | Returns the messages in a map with the row id as key. |
mergeRows(rows, incoming) | Updates the rows by key and adds the new keys at the end. |
newRowId() | Returns a new row id. |
parseKeyValueText(text, options?) | Reads key=value lines. It returns pairs and the invalid lines. |
formatKeyValueText(pairs) | Writes pairs as key=value lines. |
validateIdentifierKey | A validator for keys with letters, digits, _, . and -. A digit cannot come first. |
isIdentifierKey(key) | Returns true for a key that the validator accepts. |
VALUE_MASK | The text that replaces a masked value. |