24 — Exercises & Projects

Project 1 — Reactive Kanban Board

kanban/useBoard.ts
import { ref, computed, watch, type Ref } from 'vue'

// ── Capstone: build a Kanban board with drag-and-drop, persistence ──
// Concepts: reactive state, computed derived state, watch for persistence,
// composable composition, scoped slots for column rendering.

interface Column {
  id: string
  title: string
  cardIds: string[]
}

interface Card {
  id: string
  title: string
  description: string
  columnId: string
  order: number
  createdAt: number
}

export function useKanbanBoard() {
  const columns: Ref<Column[]> = ref([])
  const cards: Ref<Card[]> = ref([])

  // ── Computed: cards grouped by column for rendering ──
  const cardsByColumn = computed(() => {
    const map = new Map<string, Card[]>()
    for (const col of columns.value) {
      map.set(col.id, cards.value
        .filter(c => c.columnId === col.id)
        .sort((a, b) => a.order - b.order))
    }
    return map
  })

  // ── Move card: update column + order ────────────────────
  function moveCard(cardId: string, toColumnId: string, toIndex: number) {
    const card = cards.value.find(c => c.id === cardId)
    if (!card) return

    const oldColumn = columns.value.find(c => c.id === card.columnId)
    const newColumn = columns.value.find(c => c.id === toColumnId)
    if (!oldColumn || !newColumn) return

    // Remove from old column's card list
    oldColumn.cardIds = oldColumn.cardIds.filter(id => id !== cardId)

    // Insert at new position in new column
    newColumn.cardIds.splice(toIndex, 0, cardId)
    card.columnId = toColumnId

    // Re-order all cards in the new column
    newColumn.cardIds.forEach((id, i) => {
      const c = cards.value.find(card => card.id === id)
      if (c) c.order = i
    })
  }

  function addCard(columnId: string, title: string) {
    const card: Card = {
      id: crypto.randomUUID(),
      title,
      description: '',
      columnId,
      order: cards.value.filter(c => c.columnId === columnId).length,
      createdAt: Date.now(),
    }
    cards.value.push(card)
    columns.value.find(c => c.id === columnId)?.cardIds.push(card.id)
  }

  // ── Persistence: auto-save to localStorage on every change ──
  watch([columns, cards], () => {
    localStorage.setItem('kanban', JSON.stringify({
      columns: columns.value,
      cards: cards.value,
    }))
  }, { deep: true })

  // ── Restore from localStorage on init ──────────────────
  function loadFromStorage() {
    const saved = localStorage.getItem('kanban')
    if (saved) {
      const data = JSON.parse(saved)
      columns.value = data.columns
      cards.value = data.cards
    } else {
      // Default columns
      columns.value = [
        { id: 'todo', title: 'To Do', cardIds: [] },
        { id: 'progress', title: 'In Progress', cardIds: [] },
        { id: 'done', title: 'Done', cardIds: [] },
      ]
    }
  }

  loadFromStorage()

  return { columns, cards, cardsByColumn, moveCard, addCard }
}

Project 2 — Real-Time Chat with WebSocket

chat/useChat.ts
import { ref, onScopeDispose, type Ref } from 'vue'

// ── Capstone: real-time chat with WebSocket composable ──
// Concepts: WebSocket lifecycle, reconnection, message buffering,
// onScopeDispose for cleanup, reactive message list.

interface ChatMessage {
  id: string
  userId: string
  text: string
  timestamp: number
}

export function useChat(roomId: string) {
  const messages: Ref<ChatMessage[]> = ref([])
  const connected = ref(false)
  const error = ref<Error | null>(null)

  let ws: WebSocket | null = null
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null
  let reconnectAttempts = 0

  function connect() {
    // ── Clean up existing connection ──
    ws?.close()

    const url = `wss://chat.example.com/rooms/${roomId}`
    ws = new WebSocket(url)

    ws.onopen = () => {
      connected.value = true
      error.value = null
      reconnectAttempts = 0  // reset on successful connect
    }

    ws.onmessage = (event) => {
      const message: ChatMessage = JSON.parse(event.data)
      messages.value.push(message)
    }

    ws.onerror = (e) => {
      error.value = new Error('WebSocket error')
    }

    ws.onclose = () => {
      connected.value = false
      // ── Exponential backoff reconnection ────────────
      if (reconnectAttempts < 5) {
        const delay = Math.min(1000 * 2 ** reconnectAttempts, 30_000)
        reconnectTimer = setTimeout(() => {
          reconnectAttempts++
          connect()
        }, delay)
      }
    }
  }

  function send(text: string) {
    if (!ws || ws.readyState !== WebSocket.OPEN) {
      error.value = new Error('Not connected')
      return
    }
    ws.send(JSON.stringify({ text, timestamp: Date.now() }))
  }

  // ── Cleanup: close WebSocket + clear timer on scope dispose ──
  onScopeDispose(() => {
    ws?.close()
    if (reconnectTimer) clearTimeout(reconnectTimer)
  })

  connect()  // start immediately

  return { messages, connected, error, send, reconnect: connect }
}

Project 3 — SSR Blog with Nuxt

blog/pages/[slug].vue
<script setup>
// ── Capstone: Nuxt SSR blog with dynamic routes, SEO, markdown ──
// Concepts: useAsyncData for SSR data fetching, useHead for SEO,
// markdown rendering, static generation (SSG).

const route = useRoute()
const { data: post } = await useAsyncData(`post-${route.params.slug}`, async () => {
  // ── Fetch from API during SSR, hydrate on client ──
  const res = await $fetch(`/api/posts/${route.params.slug}`)
  return res
})

// ── SEO: dynamic meta tags from post data ──────────────
useHead({
  title: () => post.value?.title ?? 'Loading...',
  meta: [
    { name: 'description', content: () => post.value?.excerpt ?? '' },
    { property: 'og:title', content: () => post.value?.title ?? '' },
    { property: 'og:type', content: 'article' },
  ],
})

// ── Markdown rendering with sanitization ──────────────
import { marked } from 'marked'
import DOMPurify from 'dompurify'

const renderedHtml = computed(() => {
  if (!post.value?.content) return ''
  return DOMPurify.sanitize(marked.parse(post.value.content))
})
</script>

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
    <time :datetime="post.publishedAt">{{ new Date(post.publishedAt).toLocaleDateString() }}</time>

    <!-- Sanitized markdown content -->
    <div class="prose" v-html="renderedHtml" />

    <RouterLink to="/">Back to all posts</RouterLink>
  </article>

  <div v-else>Post not found</div>
</template>
blog/server/api/posts/[slug].get.ts
// ── Nuxt server route: API endpoint for blog posts ─────
// File-based: server/api/posts/[slug].get.ts → GET /api/posts/:slug

export default defineEventHandler(async (event) => {
  const slug = getRouterParam(event, 'slug')

  // ── Fetch from database (parameterized query — injection-safe) ──
  const post = await db.prepare('SELECT * FROM posts WHERE slug = ?').get(slug)

  if (!post) {
    throw createError({
      statusCode: 404,
      statusMessage: 'Post not found',
    })
  }

  return post
})

Project 4 — Component Library with TypeScript Generics

library/DataSelect.vue
<script setup lang="ts" generic="T extends { id: string }">
// ── Capstone: reusable type-safe DataSelect component ──
// Concepts: generic components (3.3+), defineModel, defineEmits,
// typed props with generics, composable integration.

// ── Generic prop: T extends { id: string } constrains the item type ──
// Parent: <DataSelect :items="users" v-model="selectedUserId" />
// T is inferred as User (where User has an id property).
const props = defineProps<{
  items: T[]
  labelKey: keyof T  // which property to display as the label
  placeholder?: string
}>()

const model = defineModel<string>()  // selected id (string)

// ── Computed: selected item object (derived from id + items) ──
const selectedItem = computed(() =>
  props.items.find(item => item.id === model.value) ?? null
)

// ── Computed: display labels ────────────────────────────
const labels = computed(() =>
  props.items.map(item => ({
    id: item.id,
    label: String(item[props.labelKey]),  // cast to string for display
  }))
)

const isOpen = ref(false)

function select(id: string) {
  model.value = id  // defineModel handles the emit automatically
  isOpen.value = false
}

function close() { isOpen.value = false }

// ── Click outside: close dropdown when clicking elsewhere ──
const rootRef = ref<HTMLElement | null>(null)
onClickOutside(rootRef, close)
</script>

<template>
  <div ref="rootRef" class="data-select">
    <button @click="isOpen = !isOpen">
      {{ selectedItem ? selectedItem[labelKey] : placeholder ?? 'Select...' }}
    </button>

    <ul v-if="isOpen" class="options">
      <li
        v-for="item in items"
        :key="item.id"
        @click="select(item.id)"
        :class="{ selected: item.id === model }"
      >
        {{ item[labelKey] }}
      </li>
    </ul>
  </div>
</template>
library/DataTable.vue
<script setup lang="ts" generic="T extends Record<string, any>">
// ── Generic DataTable: type-safe columns + data ──
// Concepts: generic components, scoped slots with typed props,
// dynamic column configuration, renderless data layer.

interface Column<K extends string> {
  key: K
  label: string
  sortable?: boolean
}

const props = defineProps<{
  data: T[]
  columns: Column<keyof T & string>[]
}>()

const sortBy = ref<keyof T | null>(null)
const sortDir = ref<'asc' | 'desc'>('asc')

// ── Computed: sorted data (immutable — doesn't mutate prop) ──
const sortedData = computed(() => {
  if (!sortBy.value) return props.data
  return [...props.data].sort((a, b) => {
    const av = a[sortBy.value!]
    const bv = b[sortBy.value!]
    const cmp = av < bv ? -1 : av > bv ? 1 : 0
    return sortDir.value === 'asc' ? cmp : -cmp
  })
})

function toggleSort(col: Column<keyof T & string>) {
  if (!col.sortable) return
  if (sortBy.value === col.key) {
    sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
  } else {
    sortBy.value = col.key
    sortDir.value = 'asc'
  }
}

// ── Scoped slot: pass row + column data to parent for custom rendering ──
// Parent: <DataTable :data="users" :columns="cols">
//   <template #cell="{ row, column, value }">{{ value }}</template>
// </DataTable>
</script>

<template>
  <table>
    <thead>
      <tr>
        <th
          v-for="col in columns"
          :key="col.key"
          @click="toggleSort(col)"
          :class="{ sortable: col.sortable }"
        >
          {{ col.label }}
          <span v-if="sortBy === col.key">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, index) in sortedData" :key="index">
        <td v-for="col in columns" :key="col.key">
          <!-- Scoped slot: parent gets row, column, and value ── -->
          <slot name="cell" :row="row" :column="col" :value="row[col.key]">
            {{ row[col.key] }}  <!-- default rendering if slot not filled -->
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

💡 Tips & Tricks

tips.ts
// ── 1. Generic components: use <script setup generic="T"> for type-safe reuse ──
// T is inferred from props — no manual type annotation needed at call site.
// Constraint: generic="T extends { id: string }" ensures T has an id property.

// ── 2. Persistence with watch deep:true — auto-save on any nested change ──
// watch(state, saveToStorage, { deep: true }) — fires on any nested mutation.
// Debounce the save for frequent updates (don't write to localStorage per keystroke).

// ── 3. WebSocket reconnection: exponential backoff with max attempts ──
// delay = min(1000 * 2^attempts, 30_000) — caps at 30s, gives up after N tries.
// Reset attempts to 0 on successful connection.

// ── 4. Nuxt useAsyncData: unique key per route for cache isolation ──
// useAsyncData(`post-${slug}`, fetcher) — each post has its own cache entry.
// Without unique keys, navigating between posts shows stale data.

// ── 5. Component library: expose API via defineExpose + TypeScript ──
// Generic components can expose typed methods (validate, focus, reset) for
// parent use via template refs — fully type-checked at the call site.

⚠️ Edge Cases & Gotchas

edge-cases.ts
// ── 1. Generic components: T is erased at runtime — no instanceof checks ──
// generic="T" exists only at compile time. At runtime, all generics are `any`.
// Don't rely on T for runtime logic — use props for runtime values.

// ── 2. WebSocket: onclose fires even on intentional close() ──
// If you close() on unmount, onclose still fires → triggers reconnection.
// Set a flag (intentionalClose = true) before close() and check in onclose.

// ── 3. localStorage persistence: non-serializable values break ──
// Date objects → strings, Map/Set → {}, class instances lose methods.
// Use a replacer/reviver in JSON.stringify/parse, or store primitives only.

// ── 4. Nuxt useAsyncData: errors during SSR crash the page ──
// If the fetcher throws on server, the entire page render fails (500).
// Wrap in try/catch, or use the `default` option for fallback data.

// ── 5. Kanban drag-and-drop: HTML5 drag events are finicky ──
// dragstart, dragover (preventDefault!), drop — order and preventDefault matter.
// Use a library (vuedraggable, @vueuse/integrations/useSortable) for reliability.

// ── 6. Component library generics: scoped slot props are NOT typed with T ──
// <slot :row="row" /> — row is T, but the parent's slot prop is `any` unless
// the parent explicitly types it. Vue 3.4+ improves this with defineSlots.

🧠 Spot the Bug

A WebSocket chat reconnects even after the user intentionally navigates away.

WSBug.ts
let ws: WebSocket | null = null

function connect() {
  ws = new WebSocket(url)
  ws.onclose = () => {
    // ⚠️ Reconnects even after intentional close() on unmount
    setTimeout(connect, 1000)
  }
}

onScopeDispose(() => {
  ws?.close()  // this triggers onclose → reconnection!
})
Answer

ws.close() triggers the onclose handler, which schedules a reconnection. The component is unmounted, but the WebSocket reconnects in the background — wasting resources and potentially causing errors when it tries to update unmounted reactive state.

Fix — set a flag before closing and check it in onclose:

WSFixed.ts
let ws: WebSocket | null = null
let intentionalClose = false

function connect() {
  ws = new WebSocket(url)
  ws.onclose = () => {
    if (intentionalClose) return  // don't reconnect on intentional close
    setTimeout(connect, 1000)
  }
}

onScopeDispose(() => {
  intentionalClose = true  // set flag before close
  ws?.close()               // onclose sees the flag → no reconnect
})

The lesson: WebSocket.close() triggers onclose, which runs reconnection logic. Set an intentionalClose flag before closing to prevent the reconnect handler from firing after the component unmounts.