Managing Collections with Vue 3
Work in progress
This page is a work in progress. Help complete it by contributing on GitHub!
- Basic Collection Component
- Complex Collection Items
- Reusable Collection Composable
- Drag and Drop Reordering
- Server Integration
- Validation
- Best Practices
- What's Next?
- Further Reading
Collections allow users to manage dynamic lists of items—like adding multiple phone numbers to a contact or assigning multiple roles to a user. Vue 3's reactivity makes managing collections straightforward and intuitive.
Note
[TODO: Screenshot] - Collection interface with add/remove buttons
Basic Collection Component
Here's a simple component for managing a list of items:
<template>
<div class="collection">
<h3>{{ title }}</h3>
<!-- List of items -->
<div v-for="(item, index) in items" :key="item.id" class="collection-item">
<div class="uk-grid-small" uk-grid>
<div class="uk-width-expand">
<input
v-model="item.value"
type="text"
class="uk-input"
:placeholder="placeholder"
/>
</div>
<div class="uk-width-auto">
<button
@click="removeItem(index)"
class="uk-button uk-button-danger uk-button-small"
>
<span uk-icon="trash"></span>
</button>
</div>
</div>
</div>
<!-- Add button -->
<button @click="addItem" class="uk-button uk-button-default uk-margin-top">
<span uk-icon="plus"></span> Add {{ itemName }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface CollectionItem {
id: number
value: string
}
interface Props {
title: string
itemName?: string
placeholder?: string
initialItems?: CollectionItem[]
}
const props = withDefaults(defineProps<Props>(), {
itemName: 'Item',
placeholder: 'Enter value...',
initialItems: () => []
})
const emit = defineEmits<{
change: [items: CollectionItem[]]
}>()
const items = ref<CollectionItem[]>([...props.initialItems])
let nextId = items.value.length + 1
function addItem() {
items.value.push({
id: nextId++,
value: ''
})
emit('change', items.value)
}
function removeItem(index: number) {
items.value.splice(index, 1)
emit('change', items.value)
}
// Expose items for parent component
defineExpose({
items
})
</script>
<style scoped>
.collection-item {
margin-bottom: 10px;
}
</style>
Usage:
<script setup lang="ts">
import { ref } from 'vue'
import Collection from './components/Collection.vue'
const phoneNumbers = ref([
{ id: 1, value: '555-1234' },
{ id: 2, value: '555-5678' }
])
function handleChange(items) {
console.log('Items changed:', items)
}
</script>
<template>
<Collection
title="Phone Numbers"
item-name="Phone Number"
placeholder="Enter phone number"
:initial-items="phoneNumbers"
@change="handleChange"
/>
</template>
Complex Collection Items
For more complex items with multiple fields:
<template>
<div class="role-collection">
<h3>User Roles</h3>
<div v-for="(role, index) in roles" :key="role.id" class="uk-card uk-card-default uk-card-body uk-margin-small">
<div class="uk-grid-small" uk-grid>
<!-- Role Selection -->
<div class="uk-width-1-2">
<label class="uk-form-label">Role</label>
<select v-model="role.role_id" class="uk-select">
<option v-for="r in availableRoles" :key="r.id" :value="r.id">
{{ r.name }}
</option>
</select>
</div>
<!-- Start Date -->
<div class="uk-width-1-4">
<label class="uk-form-label">Start Date</label>
<input
v-model="role.start_date"
type="date"
class="uk-input"
/>
</div>
<!-- End Date -->
<div class="uk-width-1-4">
<label class="uk-form-label">End Date</label>
<input
v-model="role.end_date"
type="date"
class="uk-input"
/>
</div>
<!-- Remove Button -->
<div class="uk-width-1-1">
<button
@click="removeRole(index)"
class="uk-button uk-button-danger uk-button-small"
>
Remove Role
</button>
</div>
</div>
</div>
<button @click="addRole" class="uk-button uk-button-primary uk-margin-top">
Add Role
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Role {
id: number
role_id: number
start_date: string
end_date: string
}
interface AvailableRole {
id: number
name: string
}
const roles = ref<Role[]>([])
const availableRoles = ref<AvailableRole[]>([
{ id: 1, name: 'Admin' },
{ id: 2, name: 'User' },
{ id: 3, name: 'Moderator' }
])
let nextId = 1
function addRole() {
roles.value.push({
id: nextId++,
role_id: 1,
start_date: new Date().toISOString().split('T')[0],
end_date: ''
})
}
function removeRole(index: number) {
roles.value.splice(index, 1)
}
</script>
Reusable Collection Composable
Create a composable for common collection logic:
composables/useCollection.ts:
import { ref, Ref } from 'vue'
interface CollectionItem {
id: number
[key: string]: any
}
export function useCollection<T extends CollectionItem>(
initialItems: T[] = [],
itemFactory: () => Omit<T, 'id'>
) {
const items: Ref<T[]> = ref([...initialItems])
let nextId = items.value.length > 0
? Math.max(...items.value.map(i => i.id)) + 1
: 1
function add(): T {
const newItem = {
id: nextId++,
...itemFactory()
} as T
items.value.push(newItem)
return newItem
}
function remove(id: number) {
const index = items.value.findIndex(item => item.id === id)
if (index > -1) {
items.value.splice(index, 1)
}
}
function removeAt(index: number) {
if (index >= 0 && index < items.value.length) {
items.value.splice(index, 1)
}
}
function update(id: number, updates: Partial<T>) {
const item = items.value.find(i => i.id === id)
if (item) {
Object.assign(item, updates)
}
}
function clear() {
items.value = []
}
function reset(newItems: T[]) {
items.value = [...newItems]
nextId = items.value.length > 0
? Math.max(...items.value.map(i => i.id)) + 1
: 1
}
return {
items,
add,
remove,
removeAt,
update,
clear,
reset
}
}
Usage:
<script setup lang="ts">
import { useCollection } from '@/composables/useCollection'
interface PhoneNumber {
id: number
label: string
number: string
}
const { items: phones, add, remove } = useCollection<PhoneNumber>(
[{ id: 1, label: 'Mobile', number: '555-1234' }],
() => ({ label: '', number: '' })
)
function addPhone() {
add()
}
function removePhone(id: number) {
remove(id)
}
</script>
<template>
<div>
<div v-for="phone in phones" :key="phone.id">
<input v-model="phone.label" placeholder="Label" />
<input v-model="phone.number" placeholder="Number" />
<button @click="removePhone(phone.id)">Remove</button>
</div>
<button @click="addPhone">Add Phone</button>
</div>
</template>
Drag and Drop Reordering
Add drag-and-drop using VueDraggable:
npm install vuedraggable@next
<script setup lang="ts">
import { ref } from 'vue'
import draggable from 'vuedraggable'
const items = ref([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' }
])
function logOrder() {
console.log('New order:', items.value)
}
</script>
<template>
<draggable
v-model="items"
item-key="id"
@end="logOrder"
>
<template #item="{ element }">
<div class="draggable-item">
<span uk-icon="table"></span>
{{ element.name }}
</div>
</template>
</draggable>
</template>
<style scoped>
.draggable-item {
padding: 10px;
margin: 5px 0;
background: #f8f8f8;
border: 1px solid #ddd;
cursor: move;
}
</style>
Server Integration
Submit collections with forms:
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
import { useCollection } from '@/composables/useCollection'
interface Email {
id: number
address: string
is_primary: boolean
}
const { items: emails, add, remove } = useCollection<Email>(
[],
() => ({ address: '', is_primary: false })
)
async function saveEmails() {
try {
await axios.post('/api/user/emails', {
emails: emails.value.map(e => ({
address: e.address,
is_primary: e.is_primary
}))
}, {
headers: {
[site.csrf.keys.name]: site.csrf.name,
[site.csrf.keys.value]: site.csrf.value
}
})
alert('Emails saved!')
} catch (error) {
console.error('Failed to save:', error)
}
}
</script>
Validation
Validate collection items:
<script setup lang="ts">
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, email: '' }
])
const errors = ref<Record<number, string>>({})
const isValid = computed(() => {
errors.value = {}
items.value.forEach(item => {
if (!item.email) {
errors.value[item.id] = 'Email is required'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(item.email)) {
errors.value[item.id] = 'Invalid email format'
}
})
return Object.keys(errors.value).length === 0
})
</script>
<template>
<div v-for="item in items" :key="item.id">
<input v-model="item.email" />
<span v-if="errors[item.id]" class="uk-text-danger">
{{ errors[item.id] }}
</span>
</div>
</template>
Best Practices
1. Use Unique Keys
Always provide unique :key for v-for:
<div v-for="item in items" :key="item.id">
<!-- content -->
</div>
2. Validate Empty Collections
Check if collection has items:
<div v-if="items.length === 0" class="uk-alert-warning" uk-alert>
No items added yet. Click "Add Item" to get started.
</div>
3. Confirm Before Removing
Prevent accidental deletions:
function removeItem(id: number) {
if (confirm('Are you sure you want to remove this item?')) {
remove(id)
}
}
4. Limit Collection Size
Prevent performance issues:
const MAX_ITEMS = 10
function addItem() {
if (items.value.length >= MAX_ITEMS) {
alert(`Maximum ${MAX_ITEMS} items allowed`)
return
}
add()
}
What's Next?
- Forms: Submit collections as part of forms
- Tables: Display collections in tables
- Alerts: Show feedback when modifying collections