Tutorial: Domina la Reactividad con `script setup` en Vue 3

¡Saludos, desarrolladores y entusiastas de Vue.js! Prepárense para un viaje emocionante al corazón de la modernidad en el desarrollo frontend. Desde su lanzamiento, Vue 3 ha sido un hito, marcando una evolución significativa en cómo construimos interfaces de usuario. La API de Composición (Composition API) fue el pilar de este cambio, ofreciendo una forma más flexible y robusta de organizar la lógica de los componentes. Pero, ¿qué pasaría si les dijera que la experiencia de usar la Composition API ha sido mejorada aún más, llevándola a un nivel de elegancia y simplicidad sin precedentes?

Aquí es donde entra en juego script setup. Esta característica, introducida en Vue 3.2, no es solo una adición más; es una verdadera revolución en la sintaxis que simplifica drásticamente la forma en que escribimos componentes de Vue, especialmente aquellos que aprovechan la Composition API. Elimina el boilerplate, mejora la legibilidad y hace que la experiencia del desarrollador sea extraordinariamente fluida. Si aún no lo han adoptado, o si están buscando una inmersión profunda para maximizar su potencial, este tutorial es para ustedes. Nos sumergiremos en script setup, desglosando sus fundamentos y construyendo una aplicación interactiva que demuestre su poder. ¡Prepárense para transformar su flujo de trabajo en Vue!

La Evolución del Componente Vue: De Opciones a Composición, y Ahora `script setup`

graphic tablet photo editor adjust sliders photo lens table

Para apreciar verdaderamente el valor de script setup, es útil entender el camino que ha recorrido Vue.js.

Originalmente, Vue 2 nos introdujo a la Options API, un enfoque declarativo donde la lógica de los componentes se organizaba en propiedades predefinidas como data, methods, computed, watch y lifecycle hooks. Este método es intuitivo para componentes pequeños y medianos, pero a medida que las aplicaciones crecen y la lógica se vuelve más compleja, la Options API puede llevar a lo que se conoce como "lógica fragmentada". Es decir, las características relacionadas pueden dispersarse por todo el componente, dificultando su comprensión y mantenimiento.

Vue 3, respondiendo a estas preocupaciones, introdujo la Composition API. Esta API permite organizar la lógica de los componentes basándose en la funcionalidad, en lugar de las opciones. Permite extraer y reutilizar lógica reactiva a través de funciones componibles (composables), lo que mejora enormemente la modularidad y la escalabilidad. La Composition API se utiliza dentro de un método setup() especial en el componente.

Sin embargo, incluso con la Composition API, el método setup() requería que explícitamente se hicieran disponibles las variables, funciones y propiedades computadas al template a través de una sentencia return. Esto, aunque funcional, aún añadía un pequeño fragmento de boilerplate. Y aquí es donde script setup brilla con luz propia.

Decodificando `script setup`: Una Declaración Más Clara y Concisa

script setup es un "azúcar sintáctico" (syntactic sugar) para la Composition API dentro de los componentes Single File Components (SFCs) de Vue. En esencia, permite escribir la lógica de la Composition API directamente en la sección <script setup> de un componente, sin necesidad de definir un objeto de opciones o un método setup() explícito. Todo lo que se declara dentro de <script setup> se expone automáticamente al template.

Beneficios Clave de script setup:

  1. Menos Boilerplate: Adiós al setup() y a la declaración explícita de return { ... }. Todas las variables, funciones e importaciones declaradas en script setup están automáticamente disponibles en el template.
  2. Mejor Rendimiento en Tiempo de Ejecución: El compilador de Vue puede optimizar los componentes <script setup> con mayor eficacia, lo que puede resultar en un rendimiento ligeramente mejor.
  3. Mejor Integración con TypeScript: script setup mejora la inferencia de tipos y simplifica el uso de TypeScript con Vue.
  4. Importaciones Automáticas de Componentes: Los componentes importados dentro de <script setup> no necesitan ser registrados explícitamente en una opción components. Se registran automáticamente.
  5. Definición de Props y Emits Simplificada: Utiliza las funciones defineProps y defineEmits para una declaración más limpia y tipada de las propiedades y eventos de un componente.

Personalmente, considero que script setup es una de las mejoras más significativas en la experiencia de desarrollo de Vue 3. La reducción de la verbosidad y la mejora en la claridad del código son palpables, haciendo que escribir componentes sea un verdadero placer.

Core Reactividad Building Blocks en `script setup`

Antes de sumergirnos en el código, revisemos rápidamente los pilares de la reactividad que usaremos extensivamente con script setup.

  • ref(): Se usa para crear una referencia reactiva a un valor. Es ideal para tipos de datos primitivos (números, cadenas, booleanos) y también puede usarse con objetos. Cuando se accede a un ref en el script setup, se hace directamente (ej. myRef.value), pero en el template, Vue lo "desenvuelve" automáticamente (ej. {{ myRef }}).
  • reactive(): Crea un objeto reactivo. A diferencia de ref, reactive solo funciona con objetos y arrays, y el acceso a sus propiedades no requiere .value. Es importante recordar que reactive no puede reasignarse directamente; si se reasigna, la reactividad se pierde.
  • computed(): Permite crear una propiedad reactiva que se deriva de otra propiedad reactiva. Es una función pura: solo se recalcula cuando sus dependencias cambian, lo que lo hace muy eficiente.
  • watch(): Se utiliza para realizar efectos secundarios en respuesta a cambios en una fuente de datos reactiva. Puede observar uno o varios refs o reactives, y soporta opciones como deep e immediate.
  • onMounted(), onUnmounted() y otros lifecycle hooks: Estas funciones nos permiten enganchar la lógica en diferentes puntos del ciclo de vida de un componente, como cuando el componente se ha montado en el DOM o antes de que se desmonte.

Pueden encontrar más detalles sobre estos conceptos en la documentación oficial de Vue sobre fundamentos de reactividad.

Hands-on Tutorial: Construyendo una Lista de Usuarios Reactiva con Búsqueda

Vamos a construir un componente sencillo pero funcional: una lista de usuarios que se obtiene de una API y permite buscar a los usuarios por nombre. Esto nos dará la oportunidad de usar ref, computed, watch y onMounted dentro de un <script setup>.

Paso 1: Configuración del Proyecto

Si aún no tienen un proyecto Vue 3, pueden crearlo fácilmente usando create-vue, la herramienta oficial de andamiaje:

npm init vue@latest

Sigan las indicaciones. Para este tutorial, pueden seleccionar "No" para TypeScript, JSX, Pinia, Vitest y Cypress, para mantenerlo lo más simple posible. Luego, naveguen a la carpeta del proyecto e instalen las dependencias:

cd <your-project-name>
npm install
npm run dev

Esto iniciará el servidor de desarrollo y podrán ver su aplicación en el navegador.

Paso 2: Creando el Componente `UserList` con `script setup`

Vamos a crear un nuevo componente llamado UserList.vue dentro de la carpeta src/components.

<!-- src/components/UserList.vue -->
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue';

// 1. Declarar estados reactivos
const users = ref([]);
const searchTerm = ref('');
const isLoading = ref(true);
const error = ref(null);

// 2. Función para obtener usuarios de una API
const fetchUsers = async () => {
  isLoading.value = true;
  error.value = null; // Resetear error
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    users.value = await response.json();
  } catch (err) {
    console.error('Failed to fetch users:', err);
    error.value = 'No se pudieron cargar los usuarios. Por favor, inténtelo de nuevo más tarde.';
  } finally {
    isLoading.value = false;
  }
};

// 3. Propiedad computada para filtrar usuarios
const filteredUsers = computed(() => {
  if (!searchTerm.value) {
    return users.value;
  }
  const lowerCaseSearchTerm = searchTerm.value.toLowerCase();
  return users.value.filter(user =>
    user.name.toLowerCase().includes(lowerCaseSearchTerm) ||
    user.email.toLowerCase().includes(lowerCaseSearchTerm)
  );
});

// 4. Observar cambios en el término de búsqueda
watch(searchTerm, (newValue, oldValue) => {
  console.log(`Término de búsqueda cambió de "${oldValue}" a "${newValue}"`);
  // Aquí podríamos implementar un debounce si la búsqueda fuera costosa
});

// 5. Hook de ciclo de vida: obtener usuarios al montar el componente
onMounted(() => {
  fetchUsers();
});
</script>

<template>
  <div class="user-list-container">
    <h2>Lista de Usuarios</h2>

    <div class="search-input-wrapper">
      <label for="search">Buscar usuario:</label>
      <input
        id="search"
        type="text"
        v-model="searchTerm"
        placeholder="Buscar por nombre o email..."
      />
    </div>

    <p v-if="isLoading" class="loading-message">Cargando usuarios...</p>
    <p v-else-if="error" class="error-message">{{ error }}</p>
    <p v-else-if="filteredUsers.length === 0" class="no-results-message">
      No se encontraron usuarios para su búsqueda.
    </p>
    <ul v-else class="user-list">
      <li v-for="user in filteredUsers" :key="user.id" class="user-item">
        <h3>{{ user.name }}</h3>
        <p>Email: {{ user.email }}</p>
        <p>Teléfono: {{ user.phone }}</p>
        <p>Ciudad: {{ user.address.city }}</p>
        <a :href="`mailto:${user.email}`" target="_blank" class="contact-link">Contactar</a>
      </li>
    </ul>
  </div>
</template>

<style scoped>
.user-list-container {
  max-width: 800px;
  margin: 40px auto;
  padding: 20px;
  background-color: #f9f9f9;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  color: #333;
}

h2 {
  text-align: center;
  color: #2c3e50;
  margin-bottom: 30px;
  font-size: 2.2em;
}

.search-input-wrapper {
  margin-bottom: 25px;
  text-align: center;
}

.search-input-wrapper label {
  font-size: 1.1em;
  color: #555;
  margin-right: 10px;
}

.search-input-wrapper input {
  width: 70%;
  padding: 12px 18px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 1.05em;
  box-shadow: inset 0 1px 3px rgba(0,0,0,0.06);
  transition: border-color 0.3s ease, box-shadow 0.3s ease;
}

.search-input-wrapper input:focus {
  border-color: #42b983;
  box-shadow: 0 0 0 3px rgba(66, 185, 131, 0.2);
  outline: none;
}

.loading-message, .error-message, .no-results-message {
  text-align: center;
  padding: 15px;
  border-radius: 6px;
  font-size: 1.1em;
  margin-top: 20px;
}

.loading-message {
  background-color: #e0f2f7;
  color: #2196f3;
}

.error-message {
  background-color: #ffe0e0;
  color: #d32f2f;
  border: 1px solid #ef9a9a;
}

.no-results-message {
  background-color: #fff9c4;
  color: #fbc02d;
}

.user-list {
  list-style: none;
  padding: 0;
  margin-top: 30px;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 25px;
}

.user-item {
  background-color: #ffffff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  transition: transform 0.3s ease, box-shadow 0.3s ease;
}

.user-item:hover {
  transform: translateY(-5px);
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}

.user-item h3 {
  color: #42b983;
  margin-top: 0;
  margin-bottom: 10px;
  font-size: 1.6em;
  border-bottom: 2px solid #e0e0e0;
  padding-bottom: 8px;
}

.user-item p {
  margin: 5px 0;
  line-height: 1.6;
  color: #666;
  font-size: 0.95em;
}

.contact-link {
  display: inline-block;
  margin-top: 15px;
  padding: 10px 15px;
  background-color: #42b983;
  color: white;
  text-decoration: none;
  border-radius: 5px;
  font-weight: bold;
  transition: background-color 0.3s ease;
}

.contact-link:hover {
  background-color: #36a575;
}
</style>

Paso 3: Integrar `UserList` en `App.vue`

Ahora, para ver nuestro componente en acción, necesitamos importarlo y usarlo en src/App.vue. Limpien el contenido existente de App.vue y reemplácenlo con lo siguiente:

<!-- src/App.vue -->
<script setup>
import UserList from './components/UserList.vue';
</script>

<template>
  <div id="app">
    <UserList />
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

Explicación del Código `UserList.vue`

  1. import { ref, reactive, computed, watch, onMounted } from 'vue';: Importamos las funciones necesarias de Vue.js. Gracias a script setup, estas funciones se pueden usar directamente sin calificarlas con this.

  2. const users = ref([]);: Creamos una referencia reactiva users que inicialmente es un array vacío. Aquí es donde almacenaremos la lista de usuarios obtenida de la API. Usamos ref porque el valor completo del array users puede ser reasignado.

  3. const searchTerm = ref('');: Otra referencia reactiva para almacenar el texto que el usuario introduce en la barra de búsqueda.

  4. const isLoading = ref(true); y const error = ref(null);: Referencias para manejar los estados de carga y error, cruciales para una buena experiencia de usuario.

  5. const fetchUsers = async () => { ... };: Esta función asíncrona realiza la llamada a la API de JSONPlaceholder para obtener los datos de los usuarios. Envuelve la lógica en un bloque try-catch para manejar posibles errores de red o del servidor, y actualiza los estados isLoading y error apropiadamente.

  6. const filteredUsers = computed(() => { ... });: Aquí está el corazón de nuestra lógica de búsqueda reactiva. computed crea una nueva lista de usuarios (filteredUsers) que se actualiza automáticamente cada vez que users.value o searchTerm.value cambian. Si no hay término de búsqueda, devuelve todos los usuarios. De lo contrario, filtra los usuarios cuyo nombre o email incluyen el searchTerm (insensible a mayúsculas/minúsculas). Usar computed es fundamental aquí para la eficiencia, ya que la función de filtrado solo se ejecuta cuando sus dependencias cambian.

  7. watch(searchTerm, (newValue, oldValue) => { ... });: Utilizamos watch para observar el searchTerm. Cada vez que cambia, se ejecuta la función de callback. En este caso, simplemente registramos el cambio en la consola. Esto es un ejemplo sencillo; en una aplicación real, watch podría usarse para guardar el término de búsqueda en el almacenamiento local, realizar una búsqueda en un servidor cuando el término alcanza una longitud mínima, o cualquier otro efecto secundario.

  8. onMounted(() => { fetchUsers(); });: Este es un lifecycle hook. La función fetchUsers() se llama una vez que el componente ha sido montado en el DOM, asegurando que los usuarios se carguen tan pronto como el componente esté listo para mostrarse. Esto es equivalente al mounted() de la Options API.

  9. Template (<template>):

    • El input de búsqueda (<input v-model="searchTerm" />) está enlazado directamente a nuestra searchTerm reactiva.
    • La lista de usuarios (<ul v-else class="user-list">) itera sobre filteredUsers, lo que significa que siempre mostrará la lista de usuarios que coincide con el término de búsqueda actual.
    • También hemos añadido lógica condicional (v-if, v-else-if, v-else) para mostrar mensajes de carga, error o sin resultados, mejorando la experiencia del usuario.

¡Y eso es todo! Con script setup, todo el código de la Composition API reside directamente en el <script setup>, sin necesidad de envolverlo en un objeto setup o retornar explícitamente las propiedades al template. Vue se encarga de todo el cableado por nosotros.

Para ver más ejemplos y profundizar, les recomiendo visitar la documentación de script setup.

Mi Opinión: La Experiencia del Desarrollador Elevada

En mi experiencia, script setup ha sido un verdadero punto de inflexión para la Composition API. Si bien la Composition API por sí sola ya era un avance en términos de organización de código y reutilización, el boilerplate de setup() y la necesidad de retornar cada variable o función al template a veces hacía que los componentes fueran un poco má