Skip to main content

InputsGroup

A dynamic list of homogeneous fields with add/remove controls. Every row holds a value of the same kind: all string or all number. The group represents a single repeated piece of data (a list of tags, a list of amounts). Validations are therefore shared across every row. If a value carries a different meaning, use a separate field instead.

InputsGroup is uncontrolled: it owns its own state, seeded once from initialValues, and reports changes through two callbacks. Mirror it into your own useState if you need the value elsewhere.

Usage

import { InputsGroup } from '@easy-ui-react/easy-ui-react'

export function Example() {
return (
<InputsGroup
label="Tags"
description="Add as many tags as you need."
initialValues={[{ value: 'react' }, { value: 'typescript' }]}
onValuesChange={(values) => console.log(values)} // ['react', 'typescript', ...] (keeps empties)
onNonEmptyValuesChange={(values) => console.log(values)} // same, without '' entries
/>
)
}

The label and description are rendered as a header above the list (the description sits right under the label). Every row shows its own validation error under its input, and the remove button is aligned with the input field.

text vs number

type (default 'text') selects both the primitive component and the value shape.

typeRendersValue typeEmpty entry
'text'Inputstring''
'number'InputNumbernumber | nullnull
<InputsGroup
type="number"
label="Amounts"
initialValues={[{ value: 10 }, { value: null }]}
inputProps={{ prefix: '$', min: 0 }}
/>

Values in and out

  • initialValues seeds the rows once: Array<{ isRequired?: boolean; value }>. Read at mount only, so later changes are ignored — to drive the values afterwards, see controlled values.
  • onValuesChange reports the raw list, keeping empty entries ('' / null).
  • onNonEmptyValuesChange reports the filtered list: string[] without '', or number[] without null.

Both callbacks report changes: they fire when a row is edited, added or removed, never at mount. Seed your own state from initialValues if you need it to be right before the first interaction — the group never emits from an effect.

const [tags, setTags] = useState<string[]>([])

<InputsGroup label="Tags" initialValues={[{ value: '' }]} onNonEmptyValuesChange={setTags} />

Controlled values

Pass values to drive what each row displays. The group then behaves like any controlled input: it reports every change, but only renders what you hand back to it.

const [tags, setTags] = useState<string[]>(['react'])

<InputsGroup label="Tags" initialValues={[{ value: 'react' }]} values={tags} onValuesChange={setTags} />

Pair values with onValuesChange, not onNonEmptyValuesChange: the filtered output drops empty entries, so a row the user just added would disappear on the way back.

values controls the values only. The structure, how many rows exist, which ones are protected, stays owned by the group and is seeded from initialValues, because a flat array carries neither row identity nor the protected flag. The group reconciles an incoming array like this:

Incoming arrayResult
Same lengthValues are updated in place, rows keep their identity and protection
LongerRemovable rows are appended
ShorterTrailing rows are dropped, except protected ones, which are emptied instead

Omit values to keep the group uncontrolled, which is the default.

Required and protected rows

isRequired is configured per initial row (not on the group). A row that is both initial and isRequired:

  • cannot be removed (no remove button is rendered for it),
  • passes isRequired to its input (shows the required error when empty).

Every other row (an initial non-required row, or any row added with the button) is optional and removable.

<InputsGroup
label="Team owners"
initialValues={[
{ value: 'owner@example.com', isRequired: true }, // mandatory, no remove button
{ value: '', isRequired: true }, // mandatory, no remove button
{ value: 'guest@example.com' }, // optional, removable
]}
/>

Shared validations

validations runs on every row (same value-level semantics as Input / InputNumber).

<InputsGroup
label="Usernames"
initialValues={[{ value: '' }]}
validations={[(value) => (value.length >= 3 ? null : 'Too short (min 3 characters)')]}
/>

Add and remove controls

  • maxItems defines the max number of rows. The add button is disabled once this number is reached (there is no minItems, use required initial rows instead).
  • isAddButtonHidden (false by default) hides the add button. It is also hidden automatically when the list is fixed, i.e. there are as many required initial rows as maxItems, so no row can ever be added or removed.
  • addButtonLabel overrides the "Add" label. The default is also configurable globally via defaults.inputsGroup.addLabel.
  • addButtonPlacement ('left', 'right' or 'full-width'; 'full-width' by default) positions the add button.
  • removeButtonPlacement ('left' or 'right'; 'right' by default) sets the side of the remove button.
  • Both the add and remove buttons use the outlined (bordered) Button variant. Override via addButtonProps / removeButtonProps.
  • renderRemoveButton fully replaces the remove button. It is not rendered for protected rows.
<InputsGroup
label="Tags"
initialValues={[{ value: 'react' }]}
renderRemoveButton={({ onRemove, index, isDisabled }) => (
<button type="button" disabled={isDisabled} onClick={onRemove}>
Remove #{index + 1}
</button>
)}
/>

isDisabled reflects the group's disabled state — not the required protection.

Slots

SlotElement
baseRoot <div>
headerWrapper around the label + description
labelGroup <label>
descriptionGroup description <p>
itemsWrapper around every row
itemOne row (input + remove button)
inputClass forwarded to each Input/InputNumber
removeButtonDefault remove button
addButtonAdd button
<InputsGroup
label="Tags"
initialValues={[{ value: 'react' }]}
classNames={{ items: 'gap-4', addButton: 'w-full justify-center' }}
/>

See global config (wrappers.inputsGroup) and presets (presets.inputsGroup) to apply these across your app.

Props

PropTypeDefaultDescription
type'text' | 'number''text'Selects the primitive and value shape
initialValuesArray<{ isRequired?: boolean; value }>[]Seeds the rows once: count, order and protection
valuesstring[] | (number | null)[]Controls the value of each row (structure stays internal)
onValuesChange(values) => voidRaw list, keeps empty entries
onNonEmptyValuesChange(values) => voidFiltered list, without '' / null
validationsArray<(value) => string | null>Shared validators applied to every row
labelstringLabel on the first row
descriptionstringDescription on the first row
errorstringError on the first row
maxItemsnumberCaps the number of rows (disables the add button at the cap)
addButtonLabelstring'Add'Add button label
addButtonPlacement'left' | 'right' | 'full-width''full-width'Position of the add button
isAddButtonHiddenbooleanfalseHides the add button (also hidden when the list is fixed)
removeButtonPlacement'left' | 'right''right'Side of the remove button
renderRemoveButton(params) => ReactNodeReplaces the default remove button
addButtonPropsOmit<ButtonProps, 'onClick' | 'children'>Props for the add button
removeButtonPropsOmit<ButtonProps, 'onClick'>Props for the default remove buttons
inputPropsOmit<InputProps | InputNumberProps, ...>Props forwarded to every input
size'sm' | 'md' | 'lg''md'Cascades to inputs and buttons
isDisabledbooleanfalseDisables inputs and buttons
isFullWidthbooleanfalseRoot spans the full width
classNamestringClass on the root
classNamesPartial<Record<InputsGroupSlots, string>>Per-slot classes
presetstringNamed preset from the global config

Storybook

Open in Storybook → and see more combinations.