Vue 3 en la práctica: Composables, contratos tipados y estado proporcional
La API de composición de Vue 3, los props tipados y las funciones emits, Pinia y la adopción incremental permiten que una aplicación se vuelva tan compleja como sea necesario, y solo cuando Vue resulta ser la elección incorrecta.
Fuentes: Vue.js Is the Frontend Framework More Teams Should Be Using
Cuando un equipo elige React, nadie pide una justificación; cuando elige Vue, casi siempre alguien lo hace. Esa reacción dice más sobre los hábitos que sobre el framework, ya que Vue 3 es una opción madura con enrutamiento y gestión de estado oficiales, soporte completo para TypeScript y una solución full-stack a través de Nuxt. Este artículo analiza Vue 3 a través de código funcional: cómo se leen el estado y los valores derivados, cómo los composables encapsulan el comportamiento, cómo las propiedades tipadas y los emites exponen los contratos de los componentes, cómo encaja Pinia y en qué casos Vue simplemente no es la elección adecuada.
El tema recurrente es la proporcionalidad. Vue ofrece suficiente estructura para aplicaciones serias sin convertir cada pequeña funcionalidad en una decisión arquitectónica, y esa característica es más fácil de juzgar en un componente real que mediante comparaciones entre funcionalidades.
Un primer componente realista: búsqueda filtrada
Todas las aplicaciones dicen poco sobre un framework. Una prueba ligeramente más realista es la búsqueda de productos: un campo de entrada, una lista de productos y el subconjunto que coincide con la consulta.
La lógica a continuación utiliza dos primitivas. ref crea un estado reactivo, en este caso el texto de búsqueda y el array de productos. computed deriva un valor a partir de ese estado y lo almacena en caché hasta que alguna de sus dependencias cambie. Obsérvese que el filtro recorta y convierte la consulta a minúsculas una sola vez, devuelve la lista completa cuando la consulta está vacía y solo entonces aplica el filtrado.
import { computed, ref } from 'vue'
interface Product {
id: number
name: string
price: number
}
const search = ref('')
const products = ref<Product[]>([
{ id: 1, name: 'Mechanical Keyboard', price: 120 },
{ id: 2, name: '4K Monitor', price: 450 },
{ id: 3, name: 'Wireless Mouse', price: 60 }
])
const filteredProducts = computed(() => {
const query = search.value.toLowerCase().trim()
if (!query) {
return products.value
}
return products.value.filter(product =>
product.name.toLowerCase().includes(query)
)
})
La plantilla se vincula directamente a esos valores. v-model mantiene el campo de entrada y search sincronizados en ambas direcciones, v-for renderiza un elemento por cada producto filtrado, y :key proporciona a Vue una identidad estable para cada fila para que pueda actualizar la lista de manera eficiente.
<input
v-model="search"
type="search"
placeholder="Search products..."
/>
<ul>
<li
v-for="product in filteredProducts"
:key="product.id"
>
{{ product.name }} - ${{ product.price }}
</li>
</ul>
No hay mucha formalidad. El estado es visible, el valor derivado es visible, y la marcaje que los utiliza también es visible. Se puede leer de arriba hacia abajo sin necesidad de aprender primero un vocabulario de mecanismos específicos del framework. Esto podría parecer una virtud menor, pero a medida que la base de código crece, la rapidez con la que alguien puede comprender un archivo desconocido se convierte en uno de los aspectos más importantes que influye un framework.
Un detalle importante: en las plantillas, las referencias se desempaquetan automáticamente, por lo que se escribe search en lugar de search.value. En el código de script, aún se utiliza .value, y por eso la función calculada lee search.value y products.value.
La API de composición: reutilizar comportamientos, no componentes
El cambio más importante entre las versiones anteriores de Vue y Vue 3 es la API de composición. La API de opciones agrupaba el código por categorías del framework (datos, métodos, cálculos, observadores), lo que dispersaba una misma funcionalidad en varias secciones. La API de composición permite agrupar el código según la función que cumple esa funcionalidad.
Tomemos una página de producto que necesita obtener datos y hacer seguimiento del estado de carga y de errores. Ese comportamiento puede trasladarse a un componente componible, una función sencilla cuyo nombre suele comenzar con use y que devuelve el estado reactivo además de las funciones que lo modifican:
import { ref } from 'vue'
export interface Product {
id: number
name: string
price: number
}
export function useProducts() {
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function loadProducts(): Promise<void> {
loading.value = true
error.value = null
try {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`)
}
products.value = (await response.json()) as Product[]
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Failed to load products.'
} finally {
loading.value = false
}
}
return {
products,
loading,
error,
loadProducts
}
}
Hay varios aspectos intencionados aquí. loading se reinicia en el bloque finally, por lo que se limpia independientemente de que la solicitud tenga éxito o fracase. error se limpia antes de cada intento para que no quede un mensaje obsoleto. Una respuesta que no sea 2xx se convierte en una excepción, ya que fetch solo rechaza las solicitudes en caso de fallos de red, no por códigos de error HTTP. Además, el bloque catch filtra err con instanceof Error antes de leer message, lo cual es necesario porque los tipos de TypeScript capturan los valores como unknown.
La distinción importante es qué se reutiliza. No se está compartiendo un componente con su marcado; se está compartiendo el comportamiento. Los componentes se centran en lo que ve el usuario, mientras que los componibles se enfocan en cómo funciona una función, y ninguno necesita convertirse en una abstracción global solo para que dos pantallas puedan compartir algo de lógica. Una advertencia: cada llamada a useProducts() crea su propio estado independiente. Por lo general, eso es lo que se desea, pero si dos componentes deben ver la misma lista de productos, se necesita un estado compartido, y ahí es donde entra en juego el almacén de datos más adelante.
Dar a cada aspecto su lugar natural
Un framework puede ser poderoso en el sentido de que ofrece muchas capacidades, o en el sentido de que reduce la brecha entre lo que se pretende y lo que se escribe. Vue tiende hacia lo segundo.
Considere un requisito que parece sencillo: cuando cambie un filtro, reflejarlo en la URL, volver a cargar los datos, preservar la paginación y mostrar un indicador de carga. Hay varias partes en movimiento, y cada una tiene un responsable evidente en una arquitectura Vue sensata:
- el componente renderiza la interfaz
- un composable implementa el comportamiento de la función
- el router gestiona el estado de la URL
- un cliente API se encarga de la comunicación con el backend
- un store almacena datos solo si realmente es necesario compartirlos en toda la aplicación
Ninguna parte individual necesita saberlo todo. Esta separación no es exclusiva de Vue, y se puede lograr en cualquier framework moderno. Lo que destaca es cómo las primitivas de Vue te guían naturalmente hacia ella.
Componentes de un solo archivo: un lugar para cada unidad de UI
Los componentes de un solo archivo de Vue combinan la plantilla, el script y los estilos en un archivo .vue. Algunos críticos lo consideran una peculiaridad estilística, pero la idea subyacente no es “poner todo en un solo archivo”. Se trata de que una unidad de interfaz de usuario esté ubicada en un único lugar. Lo importante aquí es el modelo de componentes y no el formato del archivo.
El comportamiento de un botón simple para mostrar u ocultar contenido se basa en un único valor booleano y una función que lo invierte:
import { ref } from 'vue'
const isOpen = ref(false)
function toggle(): void {
isOpen.value = !isOpen.value
}
La marcación se lee de manera igualmente directa. @click conecta el botón con la función toggle, y v-if agrega o elimina el panel según el valor de isOpen:
<button type="button" @click="toggle">
Account Settings
</button>
<div v-if="isOpen">
Manage your account preferences.
</div>
La distancia entre el estado y la interfaz de usuario es muy pequeña, y sigue siéndolo. Cuando regresas a esa función meses después para depurarla, la relación entre la bandera y lo que se muestra en pantalla sigue siendo evidente. Ten en cuenta que v-if elimina por completo el elemento del DOM; si el panel se activa y desactiva con mucha frecuencia o contiene contenido costoso, v-show, que solo lo oculta con CSS, puede ser la mejor opción.
Una buena arquitectura de componentes no se trata de minimizar el número de archivos. Se trata de hacer que las cosas relacionadas sean fáciles de encontrar.
TypeScript: dónde están los contratos
Una aplicación frontend está llena de contratos: los props que acepta un componente, los eventos que emite, los valores que devuelve un composable, los modelos que genera un cliente API, el estado y las acciones que expone una store. Cuando esos contratos son implícitos, refactorizar una gran base de código implica depender de que las personas recuerden las suposiciones en lugar de leerlas.
Vue 3 permite que esos contratos estén justo al lado de la lógica del componente. Dentro de <script setup>, defineProps y defineEmits son macros del compilador que aceptan declaraciones únicamente de tipo:
interface User {
id: number
name: string
email: string
}
const props = defineProps<{
user: User
}>()
const emit = defineEmits<{
updated: [user: User]
}>()
function save(): void {
emit('updated', props.user)
}
El tipo de las propiedades indica que el componente requiere un user del tipo User. El tipo de los eventos utiliza una firma de estilo tupla, lo que significa que el evento updated lleva exactamente un argumento, un User. Llamar a emit con el nombre de evento o carga incorrecta genera un error de tipo. Cualquiera que abra el archivo puede ver la interfaz pública del componente de un vistazo.
Esa es la parte de TypeScript que más se beneficia en aplicaciones frontales grandes. La autocompletación y las sugerencias del editor son agradables, pero son los contratos explícitos los que hacen que los cambios sean seguros de analizar y que los refactores sean menos arriesgados.
Mantén el estado lo más local posible
Una forma segura de complicar la interfaz frontal es poner todo en el estado global: un almacén para el modal, uno para los campos del formulario, uno para la fila de la tabla seleccionada y otro para las consultas de búsqueda temporales. Pronto, la mitad de la aplicación queda dentro de una capa de estado extensa donde cualquier código puede modificar cualquier cosa.
Vue permite un progreso más gradual:
- mantener el estado local dentro del componente que lo posee
- trasladar comportamientos reutilizables a composables
- colocar el estado realmente compartido en un almacén
Para ese último paso, Pinia es el almacén oficial de Vue. El ejemplo a continuación muestra un almacén de estilo opciones para un carrito de compras. state devuelve los datos iniciales, el getter total calcula la suma del carrito a partir de los artículos y sus cantidades, y las acciones modifican la lista. addItem aumenta la cantidad cuando el producto ya está en el carrito; de lo contrario, agrega una copia del artículo para que el almacén no comparta una referencia de objeto con quien lo llama.
import { defineStore } from 'pinia'
export interface CartItem {
id: number
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
}
export const useCartStore = defineStore('cart', {
state: (): CartState => ({
items: []
}),
getters: {
total: (state): number =>
state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
},
actions: {
addItem(item: CartItem): void {
const existing = this.items.find(
current => current.id === item.id
)
if (existing) {
existing.quantity += item.quantity
} else {
this.items.push({ ...item })
}
},
removeItem(itemId: number): void {
this.items = this.items.filter(
item => item.id !== itemId
)
}
}
})
La tienda no es la parte interesante; la decisión de crearla sí lo es. Un campo de texto casi nunca necesita estado global. Un carrito que son leídos tanto por la cabecera como por las páginas de los productos y el proceso de pago casi con certeza sí lo necesita. Vue deja espacio para ese juicio en lugar de forzar todo el estado en el mismo contenedor. Pinia también admite una sintaxis de tipo setup que utiliza directamente ref y computed, lo cual prefieren algunos equipos porque coincide con la forma en que se escriben los composables.
Un camino de crecimiento en lugar de un compromiso inicial
Vue no exige que decidas toda la arquitectura desde el primer día. Comienzas con Vue mismo, añades Vue Router cuando necesites enrutamiento, añades Pinia cuando necesites estado a nivel de toda la aplicación, y pasas a Nuxt cuando quieras renderizado en servidor, rutas en servidor y una configuración full-stack más amplia. Una aplicación pequeña puede seguir siendo pequeña; una grande puede ganar estructura a medida que surgen sus necesidades.
Eso es especialmente importante para los productos existentes. Vue puede integrarse en parte de una aplicación renderizada en servidor construida con Django, Laravel, Rails o PHP puro sin tener que reconstruir todo el frontend. Quizás solo un panel de análisis necesite una interactividad avanzada. Tal vez una pantalla de configuración haya superado las capacidades de los templates del servidor, o un editor necesite un comportamiento intensivo en el lado del cliente. Vue puede gestionar esa área y dejar todo lo demás intacto.
No toda interfaz frontal tiene que ser un SPA
A veces el ecosistema trata a “aplicación frontend” y “aplicación de página única” como sinónimos. Pero no lo son. Muchos productos se benefician más de páginas generadas en el servidor con unas pocas áreas interactivas seleccionadas cuidadosamente, y Vue se adapta muy bien a ese modelo.
Para los equipos que mantienen aplicaciones maduras, esto redefine por completo el proceso de migración. La pregunta deja de ser “¿cómo reescribimos el producto en Vue?” y pasa a ser “¿dónde reducirá Vue más las dificultades?”. La segunda pregunta conduce a pasos más pequeños y seguros, así como a obtener valor de manera más temprana.
El rendimiento proviene de la arquitectura, no de las pruebas de rendimiento
Vue tiene un buen rendimiento, pero los gráficos de pruebas de rendimiento son una razón débil para elegir cualquier framework. Las aplicaciones en producción rara vez son lentas porque su framework haya perdido una microprueba por unos pocos milisegundos; son lentas porque renderizan listas enormes, envían paquetes demasiado grandes, repiten solicitudes de red, mantienen demasiado estado activo o realizan operaciones costosas en el lugar incorrecto.
El modelo de Vue facilita evitar esos problemas:
- los componentes pueden mantenerse pequeños y enfocados
- el estado puede permanecer local en lugar de provocar actualizaciones en toda la aplicación
- las funciones y rutas pueden cargarse de forma perezosa
- la lógica reutilizable puede aislarse en composables
- el estado global puede limitarse a lo que realmente pertenece allí
Nada de esto garantiza automáticamente un buen rendimiento. Hace que sea más fácil comprender cómo lograrlo, lo cual vale más que una victoria en pruebas de rendimiento.
Un ecosistema maduro
Elegir Vue significaba en su momento aceptar un ecosistema más reducido. Ese argumento es mucho menos válido ahora. Existe un router oficial, Pinia para el estado de la aplicación, herramientas avanzadas para desarrolladores, una amplia selección de bibliotecas de componentes UI y Nuxt para equipos que desean un framework completo para aplicaciones.
Juntos abarcan una gran variedad de casos de uso: un único widget interactivo, una gran SPA, un sitio renderizado en el servidor o un producto full-stack. No es necesario saber qué tipo de aplicación se está creando antes de escribir la primera línea de código, y esa flexibilidad es una ventaja en sí misma.
El argumento menos medible: las decisiones se toman según las necesidades
El beneficio más difícil de cuantificar es la sensación que generan las decisiones cotidianas. Abres un componente y lo comprendes. Necesitas un comportamiento reutilizable, así que escribes un componente componible. El estado pertenece a un único componente, por lo que permanece allí. Cuando algo realmente necesita ser compartido, se traslada a un almacén de estado. La aplicación requiere enrutamiento, así que añades el router. Si la aplicación crece más allá de lo que permite una solución puramente del lado del cliente, entonces adoptas Nuxt.
Cada paso responde a la complejidad que realmente existe. Tomas decisiones arquitectónicas porque el producto las exige, no porque el framework lo imponga desde un principio. A lo largo de la vida útil de un proyecto, esa diferencia se va incrementando.
Cuándo Vue no es la opción adecuada
Vue no es necesariamente la solución adecuada. Una organización con una gran base de código en React, su propia biblioteca de componentes React, amplia experiencia interna en este framework y un proceso de contratación orientado a ingenieros especializados en React probablemente perderá más de lo que ganará al migrar. Los frameworks nunca se eligen de forma aislada; las habilidades del equipo, la infraestructura existente, los costos de contratación y migración tienen al menos tanta importancia como el modelo de programación.
Tampoco Vue te protege de una mala práctica ingenieril. Es totalmente posible crear componentes enormes, almacenes globales sobrecargados, capas de abstracción innecesarias y páginas lentas en Vue, al igual que en cualquier otro framework. Ofrece valores por defecto agradables, pero no sustituye al juicio crítico del desarrollador.
La proporción como principio de diseño
Lo que destaca de Vue es su sentido de proporción. Un problema pequeño recibe una solución pequeña. El comportamiento reutilizable se convierte en un componente componible. El estado compartido solo se transforma en un almacén cuando es necesario. La navegación corresponde al router; una aplicación más grande puede optar por Nuxt, y TypeScript define los límites sin tomar el control del código.
Nada de esto es revolucionario, y ese es precisamente el punto. Un framework no tiene que reinventar el desarrollo frontend cada pocos meses; debe hacer que los problemas existentes sean más fáciles de resolver.
Puntos clave
refycomputedde Vue 3 mantienen el estado y los valores derivados de forma explícita, lo que permite que los componentes sigan siendo legibles a medida que crecen.- Los componentes componibles comparten comportamiento en lugar de marcado; cada llamada crea un estado independiente, por lo que se debe utilizar un almacén cuando realmente sea necesario compartir datos.
defineProps y defineEmits, que solo se refieren al tipo, hacen que el contrato público de un componente sea visible y verificable por el compilador.Lecturas relacionadas
- Operadores de TypeScript en la práctica: matemáticas, comparaciones, lógica y operadores ternarios — Un recorrido práctico por los operadores aritméticos, de asignación, comparación, lógicos, de incremento y ternarios de TypeScript, incluyendo la comprobación de igualdad que rechaza el compilador.
- Contratos tipados y protecciones de Zod para un panel de análisis en tiempo real vía WebSocket — Cómo crear un panel confiable en tiempo real con TypeScript: definir contratos para los datos, validar mensajes WebSocket con Zod y evitar conexiones duplicadas.