V

Patrones Vue.js 3 — FlowMetrics Dashboard

Arquitectura Composition API • Pinia • Nuxt 3 • TypeScript

Vue 3.5 Nuxt 3 Pinia TypeScript
0
Vista previa — Dashboard FlowMetrics
Resultado del componente MetricCard.vue
Tasa de Conversión
4.82%
+0.34%
vs. semana anterior
Revenue MRR
€23.4K
+12.1%
vs. mes anterior
Abandono Carrito
68.3%
-4.2%
vs. semana anterior
Sesiones activas
1.847
+8.9%
últimas 24h
💡
Patrón aplicado: MetricCard es un componente presentacional puro. Recibe label, value, trend y period vía props tipadas. No hace fetch. El container DashboardPage.vue usa el composable useDashboardMetrics() para hidratar los datos.
1
Estructura de proyecto — Feature-First
src/
api/
metricsClient.ts API client + types
components/
base/
BaseButton.vue
BaseCard.vue
features/
MetricCard.vue presentacional
DateRangePicker.vue
FunnelChart.vue
composables/
useDashboardMetrics.ts fetch + debounce
useDebounce.ts
useDateRange.ts
pages/
dashboard/index.vue container
auth/login.vue
stores/
useDashboardStore.ts Pinia setup
useAuthStore.ts
router/
guards.ts
types/
metrics.ts
nuxt.config.ts
vitest.config.ts
PascalCase.vue useCamelCase.ts kebab-case/carpetas camelCase.ts utiles
2
Pinia Setup Store — useDashboardStore
stores/useDashboardStore.ts Setup Store syntax
// stores/useDashboardStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { type MetricSnapshot, type DateRange } from '@/types/metrics'
import { fetchMetrics } from '@/api/metricsClient'

export const useDashboardStore = defineStore('dashboard', () => {
  // ─── State ───────────────────────────────────────
  const metrics    = ref<MetricSnapshot[]>([])
  const isLoading  = ref(false)
  const error      = ref<string | null>(null)
  const dateRange  = ref<DateRange>({ from: '2026-06-11', to: '2026-06-18' })

  // ─── Computed ─────────────────────────────────────
  const conversionRate = computed(() =>
    metrics.value.find(m => m.key === 'conversion_rate')
  )
  const hasData = computed(() => metrics.value.length > 0)

  // ─── Actions ──────────────────────────────────────
  async function loadMetrics(range: DateRange) {
    isLoading.value = true
    error.value = null
    try {
      metrics.value = await fetchMetrics(range)
      dateRange.value = range
    } catch (err) {
      error.value = (err as Error).message
    } finally {
      isLoading.value = false
    }
  }

  function reset() {
    metrics.value = []
    error.value = null
  }

  return {
    // Expose ONLY what consumers need
    metrics, isLoading, error, dateRange,
    conversionRate, hasData,
    loadMetrics, reset,
  }
})
Setup Store vs Options Store: La función setup actúa como script setup — todo lo que se return queda reactivo y devtools-friendly. Cada acción async maneja los tres estados: isLoading, éxito y error.
3
Composable — useDashboardMetrics con debounce
composables/useDebounce.ts
import { ref, watch, readonly,
         onUnmounted, toValue,
         type MaybeRef } from 'vue'

export function useDebounce<T>(
  value: MaybeRef<T>,
  delay: number
): Ref<T> {
  const debounced = ref(toValue(value)) as Ref<T>
  let timer: ReturnType<typeof setTimeout>

  watch(
    () => toValue(value),
    (newVal) => {
      clearTimeout(timer)
      timer = setTimeout(() => {
        debounced.value = newVal
      }, delay)
    }
  )

  onUnmounted(() => clearTimeout(timer))
  return readonly(debounced)
}
composables/useDashboardMetrics.ts
import { watch, type Ref } from 'vue'
import { useDebounce } from './useDebounce'
import { useDashboardStore } from '@/stores'
import { type DateRange } from '@/types/metrics'

export function useDashboardMetrics(
  dateRange: Ref<DateRange>
) {
  const store = useDashboardStore()
  const debouncedRange =
    useDebounce(dateRange, 400)

  watch(
    debouncedRange,
    (range) => store.loadMetrics(range),
    { immediate: true }
  )

  return {
    metrics:  store.metrics,
    isLoading: store.isLoading,
    error:    store.error,
  }
}
4
Componente — MetricCard.vue (Composition API)
components/features/MetricCard.vue presentacional puro
<script setup lang="ts">
// 1. Imports: vue → ecosystem → absolutos → relativos
import { computed } from 'vue'

// 2. Props & Emits
interface Props {
  label:    string
  value:    string | number
  trend?:   number          // positivo = sube, negativo = baja
  period?:  string
  variant?: 'default' | 'highlight'
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'default',
  period:  'vs. periodo anterior',
})

const emit = defineEmits<{
  focus: [label: string]
}>()

// 3. Computed
const trendClass = computed(() =>
  props.trend === undefined ? ''
  : props.trend >= 0 ? 'trend-up'
  : 'trend-down'
)
const trendSign = computed(() =>
  props.trend !== undefined && props.trend >= 0 ? '↑' : '↓'
)
const trendAbs = computed(() =>
  Math.abs(props.trend ?? 0).toFixed(2)
)
</script>

<template>
  <div
    :class=["metric-card", `mc-${variant}`]
    @click="emit('focus', label)"
    role="button"
    :aria-label=`Métrica: ${label}`
  >
    <p class="metric-label">{{ label }}</p>
    <p class="metric-value">{{ value }}</p>

    <!-- Slot condicional de tendencia -->
    <slot name="trend">
      <div v-if="trend !== undefined" :class=['metric-trend', trendClass]">
        <span class="trend-arrow">{{ trendSign }}</span>
        {{ trendAbs }}%
      </div>
    </slot>

    <p class="metric-period">{{ period }}</p>
  </div>
</template>

<style scoped>
/* Scoped: estilos solo para este componente */
.metric-card { cursor: pointer; transition: transform .15s; }
.metric-card:hover { transform: translateY(-2px); }
</style>
5
Nuxt 3 — Route Guard + Auto-imports
router/guards.ts (Vue Router)
// Protege rutas con meta.requiresAuth
export function setupAuthGuard(router) {
  router.beforeEach((to) => {
    const auth = useAuthStore()
    if (to.meta.requiresAuth && !auth.isLoggedIn) {
      return {
        name:  'login',
        query: { redirect: to.fullPath },
      }
    }
  })
}

// pages/dashboard/index.vue route meta
definePageMeta({
  middleware: ['auth'],
})
pages/dashboard/index.vue (Nuxt container)
<script setup lang="ts">
// Nuxt auto-importa ref, computed, useFetch…
const dateRange = ref({
  from: '2026-06-11',
  to:   '2026-06-18',
})

// Composable encapsula fetch + debounce
const { metrics, isLoading, error } =
  useDashboardMetrics(dateRange)

// Parámetro reactivo de ruta
const route  = useRoute()
const shopId = computed(
  () => route.params.shopId as string
)
</script>

<template>
  <main>
    <div v-if="isLoading">Cargando…</div>
    <div v-else-if="error">{{ error }}</div>
    <MetricCard
      v-else
      v-for="m in metrics"
      :key="m.id"
      v-bind="m"
    />
  </main>
</template>
6
Testing — Vitest + Vue Test Utils
components/features/MetricCard.test.ts Vitest + Vue Test Utils
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import MetricCard from './MetricCard.vue'

beforeEach(() => { setActivePinia(createPinia()) })

describe('MetricCard', () => {
  it('renderiza label y value', () => {
    const w = mount(MetricCard, {
      props: { label: 'Conversión', value: '4.82%', trend: 0.34 }
    })
    expect(w.text()).toContain('Conversión')
    expect(w.text()).toContain('4.82%')
  })

  it('emite focus al hacer click', async () => {
    const w = mount(MetricCard, {
      props: { label: 'MRR', value: '€23K' }
    })
    await w.trigger('click')
    expect(w.emitted('focus')![0]).toEqual(['MRR'])
  })

  it('trend negativo aplica clase trend-down', () => {
    const w = mount(MetricCard, {
      props: { label: 'Abandono', value: '68%', trend: -4.2 }
    })
    expect(w.find('.trend-down').exists()).toBe(true)
  })
})
renderiza label y value MetricCard.test.ts:9
emite focus al hacer click MetricCard.test.ts:18
trend negativo aplica clase trend-down MetricCard.test.ts:27
✓ 3 tests passed · 0 failed · Vitest 1.6
7
Anti-patrones detectados en FlowMetrics
4 issues · prioridad alta
# Anti-patrón Por qué es un problema Fix recomendado
P1 v-if + v-for
FunnelStep.vue:34
Vue no garantiza orden de ejecución; genera re-renders innecesarios en cada iteración. computed filteredSteps + solo v-for en template.
P1 :key="index"
MetricList.vue:12
Al reordenar la lista Vue reutiliza el DOM incorrecto; estado de inputs se mezcla. :key="metric.id" — IDs estables de base de datos.
P2 mixin: DataMixin
components/legacy/
Flujo de datos opaco, conflictos de nombres, no tree-shakable. Migrar a useDashboardMetrics() composable.
P2 watch(props.range)
DateFilter.vue:28 (Vue 3.5+)
Error de compilación en Vue 3.5: destructured props no pueden ser watched directamente. watch(() => props.range, cb) — getter wrapper obligatorio.
8
APIs nuevas Vue 3.5 aplicadas al proyecto
useTemplateRef() — refs tipadas
// Antes (frágil, nombre debe coincidir)
const inputRef = ref<HTMLInputElement>()

// Ahora (Vue 3.5) — explícito y seguro
import { useTemplateRef } from 'vue'
const searchEl =
  useTemplateRef<HTMLInputElement>('search')

// En template:
// <input ref="search" />
onWatcherCleanup() — sin race conditions
import { watch, onWatcherCleanup } from 'vue'

watch(shopId, async (id) => {
  const ctrl = new AbortController()

  // Cancela el fetch anterior automáticamente
  onWatcherCleanup(
    () => ctrl.abort()
  )

  const data = await fetchShop(id, {
    signal: ctrl.signal
  })
})
Reactive props destructure (Vue 3.5)
// Vue < 3.5: ROMPE reactividad
const { label, value } = defineProps<...>()

// Vue 3.5+: destructure ES seguro
const { label = '–', value = 0 } =
  defineProps<{
    label?: string
    value?: number
  }>()

// Limitación: watch siempre con getter
watch(() => label, (v) => { /* ok */ })
Lazy hydration SSR (Nuxt)
import {
  defineAsyncComponent,
  hydrateOnVisible,
} from 'vue'

// FunnelChart solo hidrata al ser visible
// Mejora LCP en dashboards pesados
const FunnelChart =
  defineAsyncComponent({
    loader: () =>
      import('./FunnelChart.vue'),
    hydrate: hydrateOnVisible(),
  })