Skip to content

Form Repeaters

Form repeaters, also known as field arrays, are a way to create a list of fields that may repeat multiple times in a form. This is useful for creating forms that collect a dynamic number of items, like a list of emails or addresses. It can also be used for a group of fields that fill an object’s information.

Features

  • Adding, removing, and swapping items.
  • Auto-name prefixing for nested fields.
  • Min and Max item count enforcement.
  • Accessibility for add/remove and move buttons is automatically managed.

Anatomy

Field 1
Move next button
Remove item button
Iteration #1
Field 2
Move previous button
Iteration #2
Add item button

Creating a Form Repeater

To create a form repeater, you can use the useFormRepeater composable.

Typically, you need to create a FormRepeater component that you can use to structure your form fields. There is no specific element that you need to use at this time, as this is considered a utility container.

At its simplest, the useFormRepeater composable returns the items you need to render the repeater.

import { useFormRepeater, FormRepeaterProps } from '@formwerk/core';
const props = defineProps<FormRepeaterProps>();
const { items, Iteration } = useFormRepeater(props);

Note that the Iteration component used here is a component that is returned by the useFormRepeater composable. This component is a wrapper that is used to render each item in the repeater in a managed way.

Under the hood, the Iteration component prefixes the nested fields with the repeater’s name and the index of the item.

The Iteration default slot props contain the following properties:

  • removeButtonProps: The props for the remove button.
  • moveUpButtonProps: The props for the move up button.
  • moveDownButtonProps: The props for the move down button.

The items array is a readonly array of strings that represent the keys of the items in the repeater. These are unique identifiers for each item in the repeater, and you should not attempt to set them manually.

Putting it all together, you get a simple example like this:

<script setup lang="ts">
import { useFormRepeater, FormRepeaterProps } from '@formwerk/core';
const props = defineProps<FormRepeaterProps>();
const { items, Iteration, addButtonProps } = useFormRepeater(props);
</script>
<template>
<Iteration
v-for="(key, index) in items"
:key="key"
:index="index"
v-slot="{ removeButtonProps, moveUpButtonProps, moveDownButtonProps }"
>
<h3>#{{ index + 1 }}</h3>
<slot />
<button v-bind="moveUpButtonProps">Move up</button>
<button v-bind="moveDownButtonProps">Move down</button>
<button v-bind="removeButtonProps">Remove</button>
</Iteration>
<button v-bind="addButtonProps">Add</button>
</template>

Example

Here is a fully styled example that makes use of some of the fields we created in the field guides. We also integrate a TransitionGroup from Vue to animate the entering and leaving of the repeater items. This is why we are not abstracting the looping aspect of the repeater, as it gives you the flexibility to use any animation library/tool you like.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
import FormRepeater from './FormRepeater.vue';
const { handleSubmit } = useForm();
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<FormRepeater name="users">
<TextField name="email" label="Email Address" required type="email" />
<Checkbox name="isAdmin" label="Is Admin" />
</FormRepeater>
<button type="submit">Submit</button>
</form>
</template>
<script setup lang="ts">
import { useFormRepeater, type FormRepeaterProps } from '@formwerk/core';
const props = defineProps<FormRepeaterProps>();
const { items, addButtonProps, Iteration } = useFormRepeater(props);
</script>
<template>
<div class="repeater-container">
<TransitionGroup name="list">
<Iteration
v-for="(key, index) in items"
:index="index"
:key="key"
as="div"
class="repeater-item"
v-slot="{ removeButtonProps, moveUpButtonProps, moveDownButtonProps }"
>
<div class="repeater-item-content">
<slot />
</div>
<div class="repeater-item-buttons">
<button v-bind="moveUpButtonProps">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M216.49,168.49a12,12,0,0,1-17,0L128,97,56.49,168.49a12,12,0,0,1-17-17l80-80a12,12,0,0,1,17,0l80,80A12,12,0,0,1,216.49,168.49Z"
></path>
</svg>
</button>
<button v-bind="removeButtonProps" class="remove-button">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M216,48H40a12,12,0,0,0,0,24h4V208a20,20,0,0,0,20,20H192a20,20,0,0,0,20-20V72h4a12,12,0,0,0,0-24ZM188,204H68V72H188ZM76,20A12,12,0,0,1,88,8h80a12,12,0,0,1,0,24H88A12,12,0,0,1,76,20Z"
></path>
</svg>
</button>
<button v-bind="moveDownButtonProps">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 256 256"
>
<path
d="M216.49,104.49l-80,80a12,12,0,0,1-17,0l-80-80a12,12,0,0,1,17-17L128,159l71.51-71.52a12,12,0,0,1,17,17Z"
></path>
</svg>
</button>
</div>
</Iteration>
</TransitionGroup>
<button v-bind="addButtonProps" class="add-button" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<path
d="M128,24A104,104,0,1,0,232,128,104.13,104.13,0,0,0,128,24Zm40,112H136v32a8,8,0,0,1-16,0V136H88a8,8,0,0,1,0-16h32V88a8,8,0,0,1,16,0v32h32a8,8,0,0,1,0,16Z"
></path>
</svg>
Add item
</button>
</div>
</template>
108 collapsed lines
<style scoped>
.add-button {
margin-top: 1rem;
padding: 10px 12px;
border-radius: 6px;
border: none;
background-color: #3f3f46;
color: #fff;
width: max-content;
display: flex;
align-items: center;
svg {
width: 18px;
height: 18px;
margin-right: 4px;
fill: #d4d4d8;
}
&:hover {
background-color: #18181b;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.repeater-container {
display: flex;
flex-direction: column;
gap: 6px;
}
.repeater-item {
border: 1px solid #e4e4e7;
max-width: 340px;
border-radius: 6px;
display: flex;
gap: 12px;
background-color: #fff;
.repeater-item-buttons {
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
button {
border: none;
background: transparent;
cursor: pointer;
color: #0284c7;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
svg {
width: 100%;
height: 100%;
flex-shrink: 0;
}
&.remove-button {
color: rgb(239 68 68);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
color: #44403c;
}
&:hover {
background-color: rgb(244 244 245);
}
}
}
.repeater-item-content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
}
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-leave-active {
position: absolute;
}
</style>

Usage

Min and Max Items

You can enforce a minimum and maximum number of items in a repeater by passing the min and max props. The remove and add buttons will be disabled when the minimum or maximum number of items is reached.

The repeater will also automatically add items to match the minimum number of items when the form is first rendered.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
import FormRepeater from './FormRepeater.vue';
const { handleSubmit } = useForm();
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<FormRepeater name="users" min="1" max="3">
<TextField name="email" label="Email Address" required type="email" />
<Checkbox name="isAdmin" label="Is Admin" />
</FormRepeater>
<button type="submit">Submit</button>
</form>
</template>

Field Path Prefixing

Repeaters require a name prop to know which part of the form they are going to repeat. Any fields nested within a repeater will have a name that is prefixed with the repeater’s name and the index of the item. This is done automatically for you.

You can observe this by submitting the previous examples.

Unnamed Fields

As you’ve learned previously in the form guide, not having a name prop on the field will make it an uncontrolled field. This is also true for repeaters; however, if you pass an empty string as the field name, the field would still be controlled and will set its value to the iteration path directly.

Try adding new items to the following example and fill out the email fields. Notice how the values are set on the array indices directly.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import FormRepeater from './FormRepeater.vue';
const { values } = useForm();
</script>
<template>
<FormRepeater name="emails" min="1">
<TextField name="" label="Email Address" required type="email" />
</FormRepeater>
<pre>{{ values }}</pre>
</template>

If you need a field to be uncontrolled, avoid passing a name prop to the field.

API

Props

These are the properties that can be passed to the useFormRepeater composable.

NameTypeDescription
addButtonLabelThe label for the add button.
maxThe maximum number of iterations allowed.
minThe minimum number of iterations allowed.
moveDownButtonLabelThe label for the move down button.
moveUpButtonLabelThe label for the move up button.
nameThe name/path of the repeater field.
removeButtonLabelThe label for the remove button.

Returns

These are the properties in the object returned by the useFormRepeater composable.

NameTypeDescription
addAdds a number of items to the repeater, defaulting to 1. Cannot exceed the max.
addButtonPropsProps for the add item button.
insertInserts an item into the repeater at a given index.
itemsThe items in the repeater.
IterationThe iteration component. You should use this component to wrap each repeated item.
moveMoves an item in the repeater from one index to another.
removeRemoves an item from the repeater. Cannot go below the min.
swap
(indexA: number, indexB: number) => void
Swaps two items in the repeater.

<Iteration />

Props

NameTypeDescription
asThe tag name to render the iteration as.
indexThe index of the current repeated item. This is required.

Slot Props

These are the properties passed to the default slot of the Iteration component.

NameTypeDescription
moveDownButtonPropsProps for the move item down button.
moveUpButtonPropsProps for the move item up button.
removeButtonPropsProps for the remove item button.