On this page

Final project: Contact Manager with Vue 3.5, Pinia, and TypeScript

25 min read TextCh. 5 — Production

Final project — Contact Manager

Congratulations on reaching the final lesson! You have covered the full breadth of Vue 3.5 — from the reactivity system and the Composition API to routing, global state, forms, animations, and testing. Now it is time to bring everything together in a cohesive, production-quality application.

In this playground lesson you will build a Contact Manager — a single-page application that demonstrates every major concept from the course. No external dependencies beyond Vue itself; everything runs in the browser.

What you will build

The Contact Manager has four primary features:

  1. Contact list — Display contacts alphabetically, filter by category, and search by name or email.
  2. Contact detail panel — Click a contact to view full details in a slide-in panel.
  3. Create / edit modal — A validated form with all input types from the forms lesson.
  4. Favorites and categories — Toggle favorites, color-code contacts by category, and display aggregate stats.

Features and Vue concepts demonstrated

Feature Vue concepts
Reactive contact list ref<Contact[]>, computed() for filtering/sorting
Search and filter ref + computed + v-model
Add/edit modal reactive() for form draft, v-if, v-model, @submit.prevent
Delete with animation TransitionGroup, filter()
Slide-in detail panel Transition, :key for re-animation
Category avatars :style dynamic binding, computed initials
Favorites toggle Direct mutation on reactive array item
Stat counters Chained computed()
Keyboard/a11y aria-label, role="dialog", aria-modal

Architecture overview

Everything is contained in a single ContactManager.vue for playground simplicity. In a real project, you would split this into:

src/
  stores/
    contacts.ts          ← Pinia store (state + actions)
  composables/
    useContactSearch.ts  ← Search/filter logic
    useContactForm.ts    ← Form state + validation
  components/
    ContactList.vue
    ContactCard.vue
    ContactDetail.vue
    ContactForm.vue
  views/
    ContactsView.vue     ← Orchestrator

Key implementation decisions

reactive() for the form draft

The form draft uses reactive() instead of individual ref() fields. This makes Object.assign(draft, newValues) work naturally for resetting the form between operations:

const draft = reactive<ContactDraft>(blankDraft())

// Reset when opening a new contact form
Object.assign(draft, blankDraft())

// Pre-populate when editing
Object.assign(draft, {
  firstName: contact.firstName,
  // ...
})

computed() for filtered + sorted list

The filtered list uses a chain of computed() operations:

const filtered = computed(() => {
  let list = contacts.value

  // Apply category filter first (cheaper)
  if (filterCat.value !== 'all') {
    list = list.filter(c => c.category === filterCat.value)
  }

  // Then apply search (string operation)
  if (search.value.trim()) {
    const q = search.value.toLowerCase()
    list = list.filter(c =>
      `${c.firstName} ${c.lastName} ${c.email}`.toLowerCase().includes(q)
    )
  }

  // Always sort alphabetically by last name
  return list.sort((a, b) =>
    `${a.lastName}${a.firstName}`.localeCompare(`${b.lastName}${b.firstName}`)
  )
})

Since computed() is cached, the filtering only runs when contacts, filterCat, or search actually change.

TransitionGroup for the contact list

The contact list is wrapped in <TransitionGroup name="card" tag="ul">. Every contact <li> has :key="contact.id" — this is required for Vue to track insertions, deletions, and moves:

/* Items entering the list */
.card-enter-from { opacity: 0; transform: translateY(-8px); }

/* Items leaving the list */
.card-leave-to   { opacity: 0; transform: translateY(8px); }

/* Items shifting position (FLIP animation) */
.card-move       { transition: transform 0.3s ease; }

The modal uses ARIA attributes to be accessible to screen readers:

<div
  v-if="showForm"
  role="dialog"
  aria-modal="true"
  aria-label="Contact form"
  class="modal-overlay"
>

For a fully accessible modal in production, add: focus trap (move focus inside modal on open, return focus to trigger on close), Escape key to close, and prevent background scroll.

Suggested enhancements

Once you are comfortable with the base implementation, try these extensions:

1. Persistence — Extract a useLocalStorage composable and save/restore the contacts array.

2. Pinia store — Move all state and actions to useContactStore. Notice how the component becomes a thin template layer.

3. Import/export — Add buttons to export contacts as CSV and import from a JSON file using the File API.

4. Drag to reorder — Use the @vueuse/core useSortable composable to enable drag-and-drop reordering within a category.

5. Tests — Write Vitest + Vue Test Utils tests for: adding a contact, deleting a contact, search filtering, and the form validation.

6. Vue Router — Add routes /contacts, /contacts/:id, and /contacts/new so the detail panel and form have their own shareable URLs.

Congratulations!

You have completed the Vue.js Essentials course. Along the way you have learned:

  • The Vue 3.5 Composition APIref, reactive, computed, watch, watchEffect
  • Single File Components<script setup lang="ts">, scoped styles, compiler macros
  • Template syntax — all directives, control flow, dynamic bindings
  • Component communicationdefineProps, defineEmits, defineModel, provide/inject
  • Composables — reusable stateful logic, lifecycle hooks, MaybeRefOrGetter
  • Vue Router 4 — routes, guards, lazy loading, programmatic navigation
  • Pinia — stores, getters, actions, persistence, cross-store communication
  • Forms — v-model deep dive, modifiers, validation patterns, accessibility
  • Fetching and Suspense — async setup, watchEffect, error boundaries
  • Transitions — Transition, TransitionGroup, FLIP animations, JS hooks
  • Testing — Vitest, Vue Test Utils, Testing Library, Pinia testing

This knowledge forms a solid foundation for building professional Vue 3 applications. Explore the Vue Ecosystem path to go further with Nuxt, VueUse, TailwindCSS integration, and advanced patterns.

Extending the project
The natural next steps for this contact manager are: persist data to localStorage with a useLocalStorage composable, add Vue Router with individual contact detail routes, move state to a Pinia store, and add search result highlighting.
What you built
This project uses: reactive() and ref() for state, computed() for derived lists, TransitionGroup + Transition for animations, v-model for form fields, defineProps and template directives, and conditional rendering with v-if/v-for.
Production readiness
For a production contact manager, add server-side persistence (a REST API or Supabase), authentication, input sanitization, optimistic UI updates, error boundaries around async operations, and thorough unit + e2e tests.
vue
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'

// ─── Types ───────────────────────────────────────────────
interface Contact {
  id: number
  firstName: string
  lastName: string
  email: string
  phone: string
  category: 'work' | 'personal' | 'family'
  favorite: boolean
  createdAt: string
}

type ContactDraft = Omit<Contact, 'id' | 'createdAt' | 'favorite'>

// ─── State ───────────────────────────────────────────────
const contacts = ref<Contact[]>([
  {
    id: 1,
    firstName: 'Ada',
    lastName: 'Lovelace',
    email: '[email protected]',
    phone: '+1 555-0101',
    category: 'work',
    favorite: true,
    createdAt: '2026-01-15',
  },
  {
    id: 2,
    firstName: 'Grace',
    lastName: 'Hopper',
    email: '[email protected]',
    phone: '+1 555-0202',
    category: 'work',
    favorite: false,
    createdAt: '2026-02-10',
  },
  {
    id: 3,
    firstName: 'Alan',
    lastName: 'Turing',
    email: '[email protected]',
    phone: '+44 555-0303',
    category: 'personal',
    favorite: true,
    createdAt: '2026-03-05',
  },
])

const search      = ref('')
const filterCat   = ref<Contact['category'] | 'all'>('all')
const showForm    = ref(false)
const editingId   = ref<number | null>(null)
const selectedId  = ref<number | null>(null)
let   nextId      = 4

const blankDraft = (): ContactDraft => ({
  firstName: '',
  lastName: '',
  email: '',
  phone: '',
  category: 'personal',
})

const draft = reactive<ContactDraft>(blankDraft())

// ─── Derived ─────────────────────────────────────────────
const filtered = computed(() => {
  let list = contacts.value

  if (filterCat.value !== 'all') {
    list = list.filter(c => c.category === filterCat.value)
  }

  if (search.value.trim()) {
    const q = search.value.trim().toLowerCase()
    list = list.filter(c =>
      `${c.firstName} ${c.lastName} ${c.email}`.toLowerCase().includes(q)
    )
  }

  return list.sort((a, b) =>
    `${a.lastName}${a.firstName}`.localeCompare(`${b.lastName}${b.firstName}`)
  )
})

const selected = computed(() =>
  contacts.value.find(c => c.id === selectedId.value) ?? null
)

const favoriteCount = computed(() =>
  contacts.value.filter(c => c.favorite).length
)

// ─── Actions ─────────────────────────────────────────────
function openNew() {
  editingId.value = null
  Object.assign(draft, blankDraft())
  showForm.value = true
}

function openEdit(contact: Contact) {
  editingId.value = contact.id
  Object.assign(draft, {
    firstName: contact.firstName,
    lastName: contact.lastName,
    email: contact.email,
    phone: contact.phone,
    category: contact.category,
  })
  showForm.value = true
}

function saveContact() {
  if (!draft.firstName.trim() || !draft.email.trim()) return

  if (editingId.value !== null) {
    const idx = contacts.value.findIndex(c => c.id === editingId.value)
    if (idx !== -1) Object.assign(contacts.value[idx], draft)
  } else {
    contacts.value.push({
      id: nextId++,
      ...draft,
      favorite: false,
      createdAt: new Date().toISOString().slice(0, 10),
    })
  }

  showForm.value = false
}

function deleteContact(id: number) {
  contacts.value = contacts.value.filter(c => c.id !== id)
  if (selectedId.value === id) selectedId.value = null
}

function toggleFavorite(id: number) {
  const c = contacts.value.find(c => c.id === id)
  if (c) c.favorite = !c.favorite
}

function selectContact(id: number) {
  selectedId.value = selectedId.value === id ? null : id
}

// ─── Helpers ─────────────────────────────────────────────
function initials(c: Contact) {
  return `${c.firstName[0]}${c.lastName[0]}`.toUpperCase()
}

const categoryColors: Record<Contact['category'], string> = {
  work:     '#3b82f6',
  personal: '#8b5cf6',
  family:   '#10b981',
}
</script>

<template>
  <div class="app">

    <!-- ── Header ────────────────────────────── -->
    <header class="header">
      <h1 class="logo">Contacts</h1>
      <div class="stats">
        <span>{{ contacts.length }} total</span>
        <span>{{ favoriteCount }} ★ favorites</span>
      </div>
      <button type="button" class="btn-primary" @click="openNew">+ New contact</button>
    </header>

    <!-- ── Toolbar ───────────────────────────── -->
    <div class="toolbar">
      <input
        v-model="search"
        type="search"
        class="search"
        placeholder="Search by name or email…"
        aria-label="Search contacts"
      />
      <select v-model="filterCat" aria-label="Filter by category" class="filter">
        <option value="all">All categories</option>
        <option value="work">Work</option>
        <option value="personal">Personal</option>
        <option value="family">Family</option>
      </select>
    </div>

    <!-- ── Main layout ───────────────────────── -->
    <div class="layout">

      <!-- Contact list -->
      <section class="list" aria-label="Contact list">
        <p v-if="filtered.length === 0" class="empty">No contacts found.</p>
        <TransitionGroup name="card" tag="ul">
          <li
            v-for="contact in filtered"
            :key="contact.id"
            class="card"
            :class="{ selected: selectedId === contact.id }"
            @click="selectContact(contact.id)"
          >
            <div
              class="avatar"
              :style="{ background: categoryColors[contact.category] }"
              aria-hidden="true"
            >{{ initials(contact) }}</div>
            <div class="info">
              <strong>{{ contact.firstName }} {{ contact.lastName }}</strong>
              <span class="email">{{ contact.email }}</span>
            </div>
            <div class="actions">
              <button
                type="button"
                :aria-label="contact.favorite ? 'Remove from favorites' : 'Add to favorites'"
                :class="{ fav: contact.favorite }"
                @click.stop="toggleFavorite(contact.id)"
              >★</button>
              <button
                type="button"
                aria-label="Edit contact"
                @click.stop="openEdit(contact)"
              >✎</button>
              <button
                type="button"
                aria-label="Delete contact"
                class="danger"
                @click.stop="deleteContact(contact.id)"
              >✕</button>
            </div>
          </li>
        </TransitionGroup>
      </section>

      <!-- Detail panel -->
      <Transition name="slide">
        <section v-if="selected" class="detail" :key="selected.id">
          <div
            class="detail-avatar"
            :style="{ background: categoryColors[selected.category] }"
            aria-hidden="true"
          >{{ initials(selected) }}</div>
          <h2>{{ selected.firstName }} {{ selected.lastName }}</h2>
          <span class="badge" :style="{ background: categoryColors[selected.category] }">
            {{ selected.category }}
          </span>
          <dl class="detail-data">
            <dt>Email</dt>
            <dd><a :href="`mailto:${selected.email}`">{{ selected.email }}</a></dd>
            <dt>Phone</dt>
            <dd>{{ selected.phone || '—' }}</dd>
            <dt>Added</dt>
            <dd>{{ selected.createdAt }}</dd>
            <dt>Favorite</dt>
            <dd>{{ selected.favorite ? 'Yes ★' : 'No' }}</dd>
          </dl>
          <div class="detail-actions">
            <button type="button" class="btn-primary" @click="openEdit(selected)">Edit</button>
            <button type="button" class="btn-danger"  @click="deleteContact(selected.id)">Delete</button>
          </div>
        </section>
      </Transition>

    </div>

    <!-- ── Modal form ─────────────────────────── -->
    <Transition name="fade">
      <div v-if="showForm" class="modal-overlay" role="dialog" aria-modal="true" aria-label="Contact form">
        <div class="modal">
          <h2>{{ editingId !== null ? 'Edit contact' : 'New contact' }}</h2>
          <form @submit.prevent="saveContact">

            <div class="row">
              <label>
                First name *
                <input v-model.trim="draft.firstName" type="text" required autocomplete="given-name" />
              </label>
              <label>
                Last name
                <input v-model.trim="draft.lastName" type="text" autocomplete="family-name" />
              </label>
            </div>

            <label>
              Email *
              <input v-model.trim="draft.email" type="email" required autocomplete="email" />
            </label>

            <label>
              Phone
              <input v-model.trim="draft.phone" type="tel" autocomplete="tel" />
            </label>

            <fieldset>
              <legend>Category</legend>
              <label><input type="radio" v-model="draft.category" value="work" />     Work</label>
              <label><input type="radio" v-model="draft.category" value="personal" /> Personal</label>
              <label><input type="radio" v-model="draft.category" value="family" />   Family</label>
            </fieldset>

            <div class="modal-footer">
              <button type="button" @click="showForm = false">Cancel</button>
              <button type="submit" class="btn-primary">Save</button>
            </div>
          </form>
        </div>
      </div>
    </Transition>

  </div>
</template>

<style scoped>
/* Reset */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

/* Layout */
.app { font-family: system-ui, sans-serif; min-height: 100vh; background: #f8fafc; color: #1e293b; }
.header { display: flex; align-items: center; gap: 1rem; padding: 1rem 1.5rem; background: white; border-bottom: 1px solid #e2e8f0; flex-wrap: wrap; }
.logo { font-size: 1.5rem; font-weight: 700; flex: 1; }
.stats { display: flex; gap: 1rem; font-size: 0.875rem; color: #64748b; }
.toolbar { display: flex; gap: 0.75rem; padding: 1rem 1.5rem; background: white; border-bottom: 1px solid #e2e8f0; }
.search, .filter { padding: 0.5rem 0.75rem; border: 1px solid #cbd5e1; border-radius: 0.375rem; font-size: 0.875rem; }
.search { flex: 1; }
.layout { display: grid; grid-template-columns: 1fr; gap: 0; }
@media (min-width: 640px) { .layout { grid-template-columns: 340px 1fr; } }

/* Card list */
.list { overflow-y: auto; max-height: calc(100vh - 9rem); }
ul { list-style: none; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
.card { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem; cursor: pointer; transition: border-color 0.15s; }
.card:hover { border-color: #94a3b8; }
.card.selected { border-color: #3b82f6; background: #eff6ff; }
.avatar { width: 2.5rem; height: 2.5rem; border-radius: 50%; display: grid; place-items: center; color: white; font-weight: 700; font-size: 0.875rem; flex-shrink: 0; }
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.email { font-size: 0.75rem; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { display: flex; gap: 0.25rem; }
.actions button { background: none; border: 1px solid transparent; border-radius: 0.25rem; padding: 0.25rem 0.375rem; cursor: pointer; font-size: 0.875rem; color: #64748b; transition: all 0.15s; }
.actions button:hover { border-color: #cbd5e1; color: #1e293b; }
.actions button.fav { color: #f59e0b; }
.actions button.danger:hover { color: #ef4444; border-color: #fca5a5; }
.empty { padding: 2rem; text-align: center; color: #94a3b8; }

/* Detail */
.detail { padding: 2rem 1.5rem; background: white; border-left: 1px solid #e2e8f0; }
.detail-avatar { width: 5rem; height: 5rem; border-radius: 50%; display: grid; place-items: center; color: white; font-weight: 700; font-size: 1.5rem; margin-block-end: 1rem; }
.detail h2 { font-size: 1.5rem; margin-block-end: 0.5rem; }
.badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 9999px; color: white; font-size: 0.75rem; font-weight: 600; margin-block-end: 1.5rem; }
.detail-data { display: grid; grid-template-columns: max-content 1fr; gap: 0.5rem 1rem; }
dt { font-weight: 600; color: #64748b; font-size: 0.875rem; }
dd { color: #1e293b; }
.detail-actions { display: flex; gap: 0.75rem; margin-block-start: 1.5rem; }

/* Buttons */
.btn-primary { padding: 0.5rem 1rem; background: #3b82f6; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; font-weight: 500; transition: background 0.15s; }
.btn-primary:hover { background: #2563eb; }
.btn-danger  { padding: 0.5rem 1rem; background: #ef4444; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; font-weight: 500; transition: background 0.15s; }
.btn-danger:hover  { background: #dc2626; }

/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: grid; place-items: center; z-index: 100; padding: 1rem; }
.modal { background: white; border-radius: 0.75rem; padding: 2rem; width: 100%; max-width: 480px; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.modal h2 { margin-block-end: 1.5rem; }
label { display: flex; flex-direction: column; gap: 0.375rem; font-size: 0.875rem; font-weight: 500; margin-block-end: 1rem; }
input[type="text"], input[type="email"], input[type="tel"] { padding: 0.5rem 0.75rem; border: 1px solid #cbd5e1; border-radius: 0.375rem; font-size: 0.875rem; }
input:focus { outline: 2px solid #3b82f6; outline-offset: 2px; }
fieldset { border: 1px solid #e2e8f0; border-radius: 0.375rem; padding: 0.75rem; margin-block-end: 1rem; }
legend { font-weight: 600; font-size: 0.875rem; padding-inline: 0.25rem; }
fieldset label { flex-direction: row; align-items: center; gap: 0.5rem; margin: 0; font-weight: normal; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.modal-footer { display: flex; justify-content: flex-end; gap: 0.75rem; margin-block-start: 1.5rem; }
.modal-footer button:first-child { padding: 0.5rem 1rem; background: none; border: 1px solid #cbd5e1; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem; }

/* Transitions */
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from,  .fade-leave-to      { opacity: 0; }
.slide-enter-active, .slide-leave-active { transition: all 0.3s ease; }
.slide-enter-from { opacity: 0; transform: translateX(20px); }
.slide-leave-to   { opacity: 0; transform: translateX(-20px); }
.card-enter-active, .card-leave-active { transition: all 0.3s ease; }
.card-enter-from { opacity: 0; transform: translateY(-8px); }
.card-leave-to   { opacity: 0; transform: translateY(8px); }
.card-move { transition: transform 0.3s ease; }
</style>