La lección anterior terminó con un límite claro: desempaquetar por posición está muy bien para pares y ternas, pero las tareas de Nómada Tareas tienen nueve campos con nombre y ahí el orden no significa nada. Esta lección cubre la otra mitad de la sintaxis —la que manda por nombre— y la completa con el operador ... en su faceta más útil: el spread, que copia y combina objetos y arrays sin tocar los originales. Con esas dos piezas escribirás por fin el patrón que sostiene el desarrollo web moderno: la actualización inmutable, { ...tarea, estado: 'hecha' }, que produce una tarea modificada sin destruir la anterior. Y terminarás encontrándote de frente con su límite crítico —el spread copia solo el primer nivel—, que es exactamente el problema que resolverá la última lección del módulo.

Contenido

  1. Desestructuración de objetos: manda el nombre
  2. Renombrar propiedades
  3. Valores por defecto (y cómo combinarlos con el renombrado)
  4. Objetos anidados
  5. Desestructurar en los parámetros: el objeto de opciones, ya en la firma
  6. El aviso: desestructurar undefined y el truco = {}
  7. La abreviatura de propiedad
  8. Spread de arrays
  9. Spread de objetos: copiar, combinar, sobrescribir
  10. El orden importa
  11. Spread para pasar argumentos a una función
  12. Rest en objetos: quitar campos
  13. Actualizaciones inmutables
  14. Por qué esto es la base del trabajo con estado
  15. El límite crítico: el spread copia solo la superficie
  16. Errores Comunes y Consejos
  17. Ejercicios
  18. Conclusión

  1. Desestructuración de objetos: manda el nombre

La sintaxis es idéntica a la de arrays, pero con llaves en lugar de corchetes:

const tarea = {
  id: 6,
  titulo: 'Presupuesto de la carpintería',
  responsable: 'Iván',
  prioridad: 'alta',
  estado: 'pendiente',
  etiquetas: ['carpintería', 'compras'],
  horasEstimadas: 5,
  fechaLimite: '2026-09-05',
  revisor: 'Marta'
};

const { titulo, responsable, horasEstimadas } = tarea;
console.log(titulo, responsable, horasEstimadas);
// Presupuesto de la carpintería Iván 5

La diferencia esencial con los arrays:

En un array desempaquetas por posición; en un objeto, por nombre de propiedad. El orden en que escribas las variables es completamente irrelevante.

const { horasEstimadas, titulo } = tarea;    // orden distinto, mismo resultado
console.log(titulo);      // 'Presupuesto de la carpintería'

La contrapartida es que los nombres deben coincidir con los de las propiedades. Si te equivocas, obtienes undefined sin ningún aviso —el mismo problema silencioso de leer una propiedad inexistente que viste en 04-01:

const { titluo } = tarea;
console.log(titluo);      // undefined   ← errata, sin error

Y si la propiedad no existe, tampoco falla:

const { comentarios } = tarea;
console.log(comentarios); // undefined

Comparación de las dos desestructuraciones:

Arrays [ ] Objetos { }
Empareja por Posición Nombre
Los nombres los eliges Tú, libremente Deben coincidir (salvo renombrado)
Saltar elementos Con comas No hace falta: pides solo lo que quieres
Resto ...resto → array ...resto → objeto
Ideal para Pares, coordenadas, entries() Entidades con campos con nombre

  1. Renombrar propiedades

Si el nombre de la propiedad no te sirve —porque choca con una variable existente o porque quieres algo más corto— puedes renombrar con dos puntos:

const { titulo: t, horasEstimadas: horas, responsable: quien } = tarea;

console.log(t);       // 'Presupuesto de la carpintería'
console.log(horas);   // 5
console.log(quien);   // 'Iván'
// console.log(titulo);  ✗ ReferenceError: titulo no existe

Se lee al revés de lo que parece: titulo: t no significa «asigna t a titulo», sino «coge la propiedad titulo y llámala t». La regla mnemotécnica: a la izquierda de los dos puntos siempre está el nombre de la propiedad; a la derecha, el nombre de la variable. Es el mismo orden que en un literal de objeto, pero funcionando en sentido inverso.

El renombrado es imprescindible cuando desestructuras dos objetos parecidos en el mismo ámbito:

const tareaA = { titulo: 'Rediseñar la sala polivalente', horasEstimadas: 12 };
const tareaB = { titulo: 'Presupuesto de la carpintería', horasEstimadas: 5 };

const { titulo: tituloA, horasEstimadas: horasA } = tareaA;
const { titulo: tituloB, horasEstimadas: horasB } = tareaB;

console.log(`${tituloA} (${horasA} h) vs ${tituloB} (${horasB} h)`);
// Rediseñar la sala polivalente (12 h) vs Presupuesto de la carpintería (5 h)

Y también cuando la clave no es un identificador válido:

const config = { 'modo-oscuro': true, 'idioma-por-defecto': 'es' };
const { 'modo-oscuro': modoOscuro, 'idioma-por-defecto': idioma } = config;
console.log(modoOscuro, idioma);    // true es

  1. Valores por defecto (y cómo combinarlos con el renombrado)

Igual que con los arrays, y con la misma regla: el defecto solo se aplica cuando el valor es undefined.

const parcial = { titulo: 'Revisar la guillotina', horasEstimadas: 2 };

const { titulo, responsable = 'sin asignar', prioridad = 'media', revisor = null } = parcial;

console.log(responsable);   // 'sin asignar'   ← la propiedad no existía
console.log(prioridad);     // 'media'
console.log(revisor);       // null
const conNulo = { revisor: null };
const { revisor = 'sin revisor' } = conNulo;
console.log(revisor);       // null   ← null NO activa el defecto

Ese comportamiento es intencionado: null es un valor deliberado («no hay revisor y lo sabemos»), mientras que undefined significa «no se ha dicho nada». En el modelo de Nómada Tareas, revisor: null es un dato válido, así que este comportamiento es el correcto.

Renombrado y valor por defecto se combinan, poniendo el defecto después del nuevo nombre:

const { horasEstimadas: horas = 0, responsable: quien = 'sin asignar' } = { horasEstimadas: 8 };
console.log(horas);   // 8
console.log(quien);   // 'sin asignar'

El orden se lee así: «coge horasEstimadas, llámala horas, y si no viene, que valga 0».

  1. Objetos anidados

El patrón puede reproducir la estructura completa del objeto:

const proyecto = {
  nombre: 'Nómada Tareas',
  equipo: {
    coordinadora: 'Marta',
    disenador: 'Iván',
    desarrolladora: 'Lucía'
  },
  totales: { horas: 48, abiertas: 45 }
};

const { nombre, equipo: { coordinadora, desarrolladora }, totales: { horas } } = proyecto;

console.log(nombre);           // 'Nómada Tareas'
console.log(coordinadora);     // 'Marta'
console.log(desarrolladora);   // 'Lucía'
console.log(horas);            // 48
// console.log(equipo);        ✗ ReferenceError

Detalle importante que sorprende a todo el mundo: al escribir equipo: { coordinadora }, la variable equipo no se crea. Los dos puntos aquí significan «entra dentro», no «guarda esto». Si quieres las dos cosas, pídelas por separado:

const { equipo, equipo: { coordinadora: jefa } } = proyecto;
console.log(equipo.disenador);   // 'Iván'
console.log(jefa);               // 'Marta'

Y protege siempre los niveles opcionales con un valor por defecto, o tendrás un TypeError:

const sinEquipo = { nombre: 'Otro proyecto' };
// const { equipo: { coordinadora } } = sinEquipo;
// ✗ TypeError: Cannot destructure property 'coordinadora' of 'undefined'

const { equipo: { coordinadora = 'sin asignar' } = {} } = sinEquipo;
console.log(coordinadora);   // 'sin asignar'   ✓

Como en la lección anterior, la advertencia se mantiene: si necesitas más de dos niveles, la desestructuración deja de ayudar. Un proyecto.equipo.coordinadora directo se lee mejor.

  1. Desestructurar en los parámetros: el objeto de opciones, ya en la firma

Aquí está el uso más valioso de toda la lección. En 03-03 diseñaste crearTarea() recibiendo un objeto de opciones, y dentro de la función tenías que ir sacando cada campo con ??. Ahora eso se escribe directamente en la firma.

// ANTES (Módulo 3): la firma no dice nada y el cuerpo se llena de ??
function crearTareaAntigua(opciones) {
  const titulo = opciones.titulo;
  const responsable = opciones.responsable ?? 'sin asignar';
  const prioridad = opciones.prioridad ?? 'media';
  const estado = opciones.estado ?? 'pendiente';
  const horasEstimadas = opciones.horasEstimadas ?? 1;
  const revisor = opciones.revisor ?? null;
  // ...
}

// AHORA: la firma es la documentación
function crearTarea({
  id,
  titulo,
  responsable = 'sin asignar',
  prioridad = 'media',
  estado = 'pendiente',
  etiquetas = [],
  horasEstimadas = 1,
  fechaLimite,
  revisor = null
}) {
  if (typeof titulo !== 'string' || titulo.trim() === '') {
    throw new Error('El título es obligatorio.');          // R1, fail-fast de 02-05
  }
  if (!(horasEstimadas > 0 && horasEstimadas <= 40)) {
    throw new Error('Las horas deben estar entre 0 y 40.'); // R3
  }
  return { id, titulo, responsable, prioridad, estado,
           etiquetas, horasEstimadas, fechaLimite, revisor };
}

const nueva = crearTarea({
  id: 7,
  titulo: 'Revisar la guillotina de encuadernación',
  responsable: 'Lucía',
  horasEstimadas: 2,
  fechaLimite: '2026-10-20',
  etiquetas: ['encuadernación', 'mantenimiento']
});

console.log(nueva);
// { id: 7, titulo: 'Revisar la guillotina de encuadernación', responsable: 'Lucía',
//   prioridad: 'media', estado: 'pendiente',
//   etiquetas: [ 'encuadernación', 'mantenimiento' ], horasEstimadas: 2,
//   fechaLimite: '2026-10-20', revisor: null }

Compara las dos versiones y fíjate en lo que se gana:

Objeto de opciones «a mano» Desestructurado en la firma
¿Se ven los campos aceptados? No: hay que leer el cuerpo Sí, en la firma
¿Se ven los valores por defecto? No
Líneas de preparación Una por campo Cero
Riesgo de errata Alto (opciones.titluo) Igual, pero concentrado en un sitio

Funciona igual en los callbacks de los métodos de 04-05, y ahí la mejora es enorme:

const backlog = [
  { id: 1, titulo: 'Rediseñar la sala polivalente',          responsable: 'Iván',  prioridad: 'alta',  estado: 'en-curso',  horasEstimadas: 12, fechaLimite: '2026-09-30' },
  { id: 2, titulo: 'Cartelería del taller de serigrafía',    responsable: 'Marta', prioridad: 'media', estado: 'pendiente', horasEstimadas: 6,  fechaLimite: '2026-10-15' },
  { id: 3, titulo: 'Actualizar la web de reservas',          responsable: 'Lucía', prioridad: 'alta',  estado: 'pendiente', horasEstimadas: 14, fechaLimite: '2026-10-02' },
  { id: 4, titulo: 'Inventario de tintas de serigrafía',     responsable: 'Marta', prioridad: 'baja',  estado: 'hecha',     horasEstimadas: 3,  fechaLimite: '2026-09-12' },
  { id: 5, titulo: 'Guía de encuadernación para residentes', responsable: 'Iván',  prioridad: 'media', estado: 'en-curso',  horasEstimadas: 8,  fechaLimite: '2026-11-05' },
  { id: 6, titulo: 'Presupuesto de la carpintería',          responsable: 'Iván',  prioridad: 'alta',  estado: 'pendiente', horasEstimadas: 5,  fechaLimite: '2026-09-05' }
];

// Sin desestructurar
const lineasA = backlog.map((t) => `${t.titulo} · ${t.responsable} · ${t.horasEstimadas} h`);

// Desestructurando en el parámetro: dentro no hay ni un solo "t."
const lineasB = backlog.map(({ titulo, responsable, horasEstimadas }) =>
  `${titulo} · ${responsable} · ${horasEstimadas} h`
);

console.log(lineasB[5]);   // 'Presupuesto de la carpintería · Iván · 5 h'

Y en una función de dominio como estaVencida, la firma pasa a declarar exactamente qué campos necesita:

function estaVencida({ fechaLimite, estado }, hoy) {
  return fechaLimite < hoy && estado !== 'hecha';       // R10
}

console.log(estaVencida(backlog[5], '2026-09-20'));   // true

Un matiz de criterio: desestructurar en la firma hace explícita la dependencia, pero pierdes el objeto completo dentro de la función. Si necesitas también el objeto entero (por ejemplo para devolverlo), recíbelo normal y desestructura en la primera línea del cuerpo.

  1. El aviso: desestructurar undefined y el truco = {}

Si llamas a crearTarea() sin argumentos, el programa revienta:

// crearTarea();
// ✗ TypeError: Cannot destructure property 'id' of 'undefined' as it is undefined.

No se puede desempaquetar lo que no existe. La solución es dar un valor por defecto al parámetro entero:

function resumirOpciones({ orden = 'fecha', limite = 10, incluirHechas = false } = {}) {
  return `orden=${orden}, limite=${limite}, hechas=${incluirHechas}`;
}

console.log(resumirOpciones());                     // orden=fecha, limite=10, hechas=false
console.log(resumirOpciones({ limite: 3 }));        // orden=fecha, limite=3, hechas=false
console.log(resumirOpciones({ orden: 'horas', incluirHechas: true }));
// orden=horas, limite=10, hechas=true

Ese = {} final se lee: «si no me pasan nada, desestructura un objeto vacío», y entonces todos los defectos individuales entran en juego. Escríbelo siempre que todos los campos sean opcionales; es la diferencia entre una función que se puede llamar sin argumentos y una que explota.

El mismo problema aparece fuera de las funciones, y la protección es ?? {}:

const respuesta = { datos: undefined };
const { total = 0 } = respuesta.datos ?? {};
console.log(total);      // 0   ✓ sin TypeError

  1. La abreviatura de propiedad

Un pequeño complemento que llevas viendo desde 04-06. Cuando el nombre de la variable coincide con el de la propiedad, no hace falta repetirlo:

const id = 7;
const titulo = 'Revisar la guillotina';
const responsable = 'Lucía';

// Forma larga
const tareaLarga = { id: id, titulo: titulo, responsable: responsable };

// Abreviatura de propiedad: idéntica, pero sin repetir
const tarea = { id, titulo, responsable };

console.log(tarea);   // { id: 7, titulo: 'Revisar la guillotina', responsable: 'Lucía' }

Es la operación inversa de la desestructuración: esta empaqueta, aquella desempaqueta. Y se combinan de forma natural en el patrón «recibir, transformar, devolver»:

function normalizarTarea({ titulo, responsable, etiquetas = [] }) {
  const tituloLimpio = titulo.trim();
  const etiquetasLimpias = etiquetas.map((e) => e.trim().toLowerCase());   // R9
  return { titulo: tituloLimpio, responsable, etiquetas: etiquetasLimpias };
}

console.log(normalizarTarea({ titulo: '  Presupuesto de la carpintería ',
                              responsable: 'Iván',
                              etiquetas: [' Carpintería', 'COMPRAS'] }));
// { titulo: 'Presupuesto de la carpintería', responsable: 'Iván',
//   etiquetas: [ 'carpintería', 'compras' ] }

Fíjate en responsable a secas en el objeto devuelto: es la abreviatura, porque el valor no cambia.

  1. Spread de arrays

El operador ... tiene dos caras. En el lado izquierdo de una asignación o en los parámetros recoge (es rest, lo que ya conoces). En el lado derecho, dentro de un literal o de una llamada, despliega: es el spread.

const deIvan = ['Rediseñar la sala polivalente', 'Guía de encuadernación'];
const deMarta = ['Cartelería del taller de serigrafía'];

// Copiar
const copia = [...deIvan];
console.log(copia);              // [ 'Rediseñar la sala polivalente', 'Guía de encuadernación' ]
console.log(copia === deIvan);   // false   ← es un array nuevo

// Combinar (sustituye a concat)
const todas = [...deIvan, ...deMarta];
console.log(todas.length);       // 3

// Añadir elementos al principio, en medio o al final
const conUrgente = ['URGENTE', ...deIvan];
const enMedio = [deIvan[0], 'Nueva tarea intercalada', ...deIvan.slice(1)];
console.log(enMedio.length);     // 3

// Funciona con cualquier iterable
console.log([...'Nómada']);      // [ 'N', 'ó', 'm', 'a', 'd', 'a' ]
console.log([...new Set([1, 1, 2])]);   // [ 1, 2 ]

Ese último ejemplo es el idioma corto para deduplicar que anunciamos en 04-05: [...new Set(array)] equivale a Array.from(new Set(array)).

Y esta es la forma moderna de escribir las operaciones inmutables sobre listas que en 04-03 hacías con slice y concat:

const etiquetas = ['carpintería', 'compras'];

const conNueva = [...etiquetas, 'urgente'];               // añadir al final
const sinPrimera = etiquetas.slice(1);                    // quitar la primera
const insertada = [...etiquetas.slice(0, 1), 'presupuestos', ...etiquetas.slice(1)];

console.log(conNueva);      // [ 'carpintería', 'compras', 'urgente' ]
console.log(insertada);     // [ 'carpintería', 'presupuestos', 'compras' ]
console.log(etiquetas);     // [ 'carpintería', 'compras' ]   ✓ intacto

  1. Spread de objetos: copiar, combinar, sobrescribir

Lo mismo, con llaves. El spread de objetos copia las propiedades propias y enumerables dentro de un objeto nuevo.

const tarea = backlog[5];

// Copiar
const copia = { ...tarea };
console.log(copia.titulo);         // 'Presupuesto de la carpintería'
console.log(copia === tarea);      // false   ← objeto nuevo

// Combinar dos objetos
const valoresPorDefecto = { prioridad: 'media', estado: 'pendiente', revisor: null, etiquetas: [] };
const datosDelFormulario = { id: 8, titulo: 'Comprar tinta negra', responsable: 'Marta' };

const nueva = { ...valoresPorDefecto, ...datosDelFormulario };
console.log(nueva);
// { prioridad: 'media', estado: 'pendiente', revisor: null, etiquetas: [],
//   id: 8, titulo: 'Comprar tinta negra', responsable: 'Marta' }

// Sobrescribir un campo concreto
const terminada = { ...tarea, estado: 'hecha' };
console.log(terminada.estado);     // 'hecha'
console.log(tarea.estado);         // 'pendiente'   ✓ el original, intacto

Ese último bloque es el patrón que da nombre a la lección, y lo desarrollaremos en el apartado 13.

Existe también Object.assign(destino, ...fuentes), que hacía lo mismo antes de que el spread existiera. Con una diferencia peligrosa:

// Object.assign MUTA el primer argumento
const destino = { a: 1 };
Object.assign(destino, { b: 2 });
console.log(destino);              // { a: 1, b: 2 }   ← ha cambiado

// Salvo que le pases un objeto vacío como destino
const seguro = Object.assign({}, { a: 1 }, { b: 2 });   // equivale a { ...{a:1}, ...{b:2} }

En código nuevo, usa el spread: hace lo mismo, no muta nada por accidente y se lee mejor.

  1. El orden importa

Cuando dos objetos tienen la misma clave, gana el último. Esta regla es la que hace útil el spread, y la que produce los bugs cuando se olvida.

const base = { prioridad: 'media', estado: 'pendiente' };
const cambios = { prioridad: 'alta' };

console.log({ ...base, ...cambios });   // { prioridad: 'alta', estado: 'pendiente' }   ✓
console.log({ ...cambios, ...base });   // { prioridad: 'media', estado: 'pendiente' }  ✗

En la segunda línea, base se aplica después y machaca el cambio. La regla mnemotécnica es directa: los valores por defecto van primero, los datos reales después.

function conValoresPorDefecto(datos) {
  return { prioridad: 'media', estado: 'pendiente', revisor: null, etiquetas: [], ...datos };
  //       ↑ defectos primero                                                    ↑ datos ganan
}

console.log(conValoresPorDefecto({ titulo: 'Comprar tinta', prioridad: 'alta' }));
// { prioridad: 'alta', estado: 'pendiente', revisor: null, etiquetas: [], titulo: 'Comprar tinta' }

Un detalle a tener presente: el spread copia también las propiedades cuyo valor es undefined, y esas machacan el valor por defecto.

console.log(conValoresPorDefecto({ prioridad: undefined }));
// { prioridad: undefined, ... }   ✗ el defecto se ha perdido

Si eso te preocupa, filtra los undefined antes o usa desestructuración con valores por defecto, que sí los ignora.

  1. Spread para pasar argumentos a una función

El spread despliega un array en argumentos sueltos, sustituyendo a apply (04-02):

const horas = [12, 6, 14, 3, 8, 5];

console.log(Math.max(...horas));    // 14
console.log(Math.min(...horas));    // 3

// Antes de ES2015 había que escribir:
console.log(Math.max.apply(null, horas));   // 14

Es la contrapartida exacta del parámetro ...rest que estudiaste en 03-03:

function registrar(accion, ...detalles) {     // rest: RECOGE los argumentos en un array
  console.log(`${accion}: ${detalles.join(' / ')}`);
}

const datos = ['Iván', 'alta', '5 h'];
registrar('Nueva tarea', ...datos);           // spread: DESPLIEGA el array en argumentos
// Nueva tarea: Iván / alta / 5 h
Dónde aparece ... Cómo se llama Qué hace
En los parámetros de una función Rest Recoge los argumentos sobrantes en un array
En un patrón de desestructuración Rest Recoge lo que quede
Dentro de un literal [ ] o { } Spread Despliega el contenido
En una llamada a función Spread Despliega el array en argumentos

Regla mnemotécnica: a la izquierda del = recoge, a la derecha reparte.

  1. Rest en objetos: quitar campos

En un patrón de objeto, ...resto recoge en un objeto nuevo todas las propiedades que no hayas nombrado. Es la forma idiomática de quitar campos sin delete:

const tarea = backlog[0];

const { id, ...sinId } = tarea;
console.log(id);                     // 1
console.log(Object.keys(sinId));
// [ 'titulo', 'responsable', 'prioridad', 'estado', 'horasEstimadas', 'fechaLimite' ]

// Quitar varios a la vez
const { id: _id, fechaLimite: _f, ...paraElInforme } = tarea;
console.log(Object.keys(paraElInforme));
// [ 'titulo', 'responsable', 'prioridad', 'estado', 'horasEstimadas' ]

Como con los arrays, el rest debe ir el último y siempre produce un objeto (vacío si no queda nada).

Un caso muy real: preparar los datos que se envían a algún sitio, sin campos internos.

function paraExportar(tarea) {
  const { revisor, etiquetas, ...publica } = tarea;
  return publica;
}

Y compara con delete, que ya conoces de 04-01:

// ✗ Muta el objeto original: quien lo tuviera se queda sin campo
delete tarea.revisor;

// ✓ Crea un objeto nuevo sin ese campo; el original no se toca
const { revisor, ...sinRevisor } = tarea;

  1. Actualizaciones inmutables

Ahora sí, el patrón central. La pregunta es: ¿cómo se cambia el estado de una tarea?

// Opción A: MUTAR
function marcarHechaMutando(tarea) {
  tarea.estado = 'hecha';
  return tarea;
}

// Opción B: crear una tarea NUEVA con el campo cambiado
function marcarHecha(tarea) {
  return { ...tarea, estado: 'hecha' };
}

const original = backlog[5];
const actualizada = marcarHecha(original);

console.log(actualizada.estado);   // 'hecha'
console.log(original.estado);      // 'pendiente'   ✓ el original sigue igual
console.log(actualizada === original);   // false
flowchart LR
    A["tarea original<br/>estado: 'pendiente'"] -->|"{ ...tarea, estado: 'hecha' }"| B["tarea NUEVA<br/>estado: 'hecha'"]
    A -.->|"sigue existiendo,<br/>sin cambios"| A2["tarea original<br/>estado: 'pendiente'"]

El mismo patrón se extiende a la lista completa, combinando spread con map (04-04):

/** Devuelve un backlog NUEVO con la tarea `id` actualizada. */
function actualizarTarea(tareas, id, cambios) {
  return tareas.map((tarea) => (tarea.id === id ? { ...tarea, ...cambios } : tarea));
}

const backlogV2 = actualizarTarea(backlog, 6, { estado: 'hecha', revisor: 'Marta' });

console.log(backlogV2[5].estado);   // 'hecha'
console.log(backlog[5].estado);     // 'pendiente'   ✓ intacto
console.log(backlogV2 === backlog); // false
console.log(backlogV2[0] === backlog[0]);   // true  ← las NO modificadas se reutilizan

Esa última línea es una propiedad valiosa: las tareas que no cambian son literalmente el mismo objeto, así que comparar con === te dice al instante qué ha cambiado sin recorrer nada. Es el fundamento de las optimizaciones de renderizado que verás en el Módulo 10.

Y el juego completo de operaciones inmutables sobre el backlog:

const agregar = (tareas, tarea) => [...tareas, tarea];
const eliminar = (tareas, id) => tareas.filter((t) => t.id !== id);
const actualizar = (tareas, id, cambios) =>
  tareas.map((t) => (t.id === id ? { ...t, ...cambios } : t));
const reordenar = (tareas, cmp) => tareas.toSorted(cmp);

const v1 = agregar(backlog, { id: 7, titulo: 'Revisar la guillotina', responsable: 'Lucía',
                              prioridad: 'media', estado: 'pendiente', horasEstimadas: 2,
                              fechaLimite: '2026-10-20' });
const v2 = actualizar(v1, 6, { estado: 'hecha' });
const v3 = eliminar(v2, 4);

console.log(backlog.length, v1.length, v2.length, v3.length);   // 6 7 7 6

Cuatro versiones del backlog conviviendo, y ninguna ha destruido a la anterior. Deshacer un cambio es tan simple como quedarse con la versión previa.

  1. Por qué esto es la base del trabajo con estado

Puede parecer un capricho crear objetos nuevos en lugar de modificar los que hay. No lo es, y conviene entender por qué antes de llegar al Módulo 10.

Ventaja Qué significa en la práctica
Detectar cambios es trivial anterior === nuevo responde en un instante, sin comparar campo a campo
Historial gratis Guardando las versiones anteriores tienes deshacer/rehacer sin esfuerzo
Sin efectos a distancia Nadie modifica por sorpresa un objeto que tú tenías guardado
Depuración predecible Puedes inspeccionar el estado en cada paso porque no se sobrescribe
Concurrencia segura Ningún dato cambia bajo los pies de una operación en curso (Módulo 5)

Los frameworks modernos están construidos sobre esta idea: en React, actualizar el estado significa devolver un objeto nuevo, no modificar el que había; en Redux, el reductor es literalmente una función que recibe el estado y devuelve uno nuevo —y sí, se llama reductor por la misma razón que reduce—. Lo verás con detalle en Por Qué Existen los Frameworks y Redux. Lo que importa ahora es que la sintaxis que acabas de aprender, { ...tarea, estado: 'hecha' }, es exactamente la que escribirás allí.

El coste, para ser honestos: crear copias consume memoria y tiempo. Con seis tareas es irrelevante; con listas enormes hay técnicas específicas, y es tema del Módulo 9. La recomendación práctica sigue siendo la misma: inmutable por defecto, y muta solo dentro de una función, sobre datos que esa función ha creado y que nadie más ve —como hiciste en el reduce del ranking de carga en 04-05.

  1. El límite crítico: el spread copia solo la superficie

Y aquí llega el problema que esta lección no puede resolver. Prueba esto:

const tarea = {
  id: 1,
  titulo: 'Rediseñar la sala polivalente',
  etiquetas: ['espacio', 'diseño']
};

const copia = { ...tarea };

// Cambiar un campo simple: perfecto, son independientes
copia.titulo = 'Otro título';
console.log(tarea.titulo);      // 'Rediseñar la sala polivalente'  ✓

// Cambiar algo DENTRO del array: desastre
copia.etiquetas.push('mobiliario');
console.log(tarea.etiquetas);   // [ 'espacio', 'diseño', 'mobiliario' ]  ✗ ¡el original ha cambiado!
console.log(copia.etiquetas === tarea.etiquetas);   // true  ← es el MISMO array

La explicación viene de 01-05: los valores primitivos se copian; los objetos y arrays se copian por referencia. El spread copia los valores de las propiedades, y el valor de etiquetas es una referencia. Resultado: dos objetos distintos que apuntan al mismo array.

flowchart TD
    A["tarea<br/>{ id, titulo, etiquetas }"] --> C["array<br/>['espacio', 'diseño']"]
    B["copia<br/>{ id, titulo, etiquetas }"] --> C
    C -.->|"push desde cualquiera<br/>afecta a los dos"| C

Esto se llama copia superficial (shallow copy): copia un nivel. Para los campos primitivos de una tarea —id, titulo, estado, horasEstimadas, fechaLimite— es suficiente. Para etiquetas y para las subtareas anidadas, no.

Hay un apaño manual, que consiste en desplegar también el nivel interior:

const copiaBuena = { ...tarea, etiquetas: [...tarea.etiquetas] };
copiaBuena.etiquetas.push('mobiliario');
console.log(tarea.etiquetas);   // [ 'espacio', 'diseño' ]   ✓ a salvo

Funciona, pero exige conocer y escribir a mano cada nivel anidado. Con el árbol de subtareas de 03-07, que puede tener cualquier profundidad, es directamente inviable. Necesitas una copia profunda, y las herramientas para conseguirla —structuredClone, el viejo truco con JSON y sus limitaciones— son el contenido de JSON y Copias de Objetos.

Errores Comunes y Consejos

1. Leer el renombrado al revés. { titulo: t } significa «la propiedad titulo se llamará t», no lo contrario.

2. Creer que al desestructurar un objeto anidado se crea la variable intermedia. const { equipo: { jefa } } = p; no crea equipo.

3. Olvidar el = {} en un parámetro desestructurado. Llamar a la función sin argumentos lanza un TypeError.

4. Poner los datos antes de los valores por defecto al combinar con spread. Los defectos van primero; lo que gana es lo último.

5. Usar Object.assign(obj, cambios) creyendo que copia. Muta el primer argumento. Usa { ...obj, ...cambios }.

6. Poner el rest en medio. const { ...resto, id } = tarea; es un SyntaxError.

7. Confiar en que el spread hace una copia completa. Copia un solo nivel: los arrays y objetos anidados se comparten.

8. Desestructurar veinte campos porque sí. Si la función usa tres, pide tres. Una firma con quince nombres es peor que (tarea).

Consejo profesional. Usa la desestructuración para declarar lo que necesitas y el spread para declarar lo que cambias. function estaVencida({ fechaLimite, estado }, hoy) dice, sin comentarios, que la regla R10 solo depende de esos dos campos; y { ...tarea, estado: 'hecha' } dice, sin comentarios, que lo único que cambia es el estado. Ese estilo declarativo es la mitad del valor de esta sintaxis; la otra mitad es no romper nada por el camino.

Ejercicios

Ejercicio 1 — Firma que se documenta sola. Reescribe estas dos funciones desestructurando en los parámetros, con valores por defecto donde tenga sentido, y de modo que puedan llamarse sin argumentos sin lanzar error:

function resumenDeTarea(tarea) {
  return tarea.titulo + ' (' + tarea.responsable + ', ' + tarea.horasEstimadas + ' h)';
}

function filtrarBacklog(tareas, opciones) {
  const estado = opciones.estado;
  const minimoHoras = opciones.minimoHoras || 0;
  return tareas.filter((t) => (!estado || t.estado === estado) && t.horasEstimadas >= minimoHoras);
}

Ejercicio 2 — Cambios de estado sin daños. Escribe estas tres funciones, todas inmutables:

  1. cambiarEstado(tareas, id, nuevoEstado) — devuelve un backlog nuevo con esa tarea en el estado indicado; si el estado no es válido, lanza un Error.
  2. asignarRevisor(tareas, id, revisor) — igual, pero cambiando el revisor.
  3. sinCamposInternos(tarea) — devuelve la tarea sin revisor ni etiquetas, usando rest.

Comprueba después de cada una que el backlog original no ha cambiado.

Ejercicio 3 — Fusión de plantillas. Taller Nómada tiene plantillas de tarea por tipo de encargo. Escribe desdePlantilla(plantilla, datos) que combine tres capas: unos valores por defecto globales, la plantilla y los datos concretos, en el orden correcto de precedencia. Usa esta plantilla y estos datos:

const PLANTILLA_SERIGRAFIA = { prioridad: 'media', etiquetas: ['serigrafía'], horasEstimadas: 4, revisor: 'Marta' };
const datos = { id: 9, titulo: 'Camisetas del curso de otoño', responsable: 'Iván', horasEstimadas: 6 };

Comprueba además que modificar las etiquetas del resultado no modifica las de la plantilla, y explica qué has tenido que hacer para conseguirlo.

Soluciones

Ejercicio 1

function resumenDeTarea({ titulo = 'sin título', responsable = 'sin asignar', horasEstimadas = 0 } = {}) {
  return `${titulo} (${responsable}, ${horasEstimadas} h)`;
}

function filtrarBacklog(tareas = [], { estado, minimoHoras = 0 } = {}) {
  return tareas.filter((t) => (!estado || t.estado === estado) && t.horasEstimadas >= minimoHoras);
}

console.log(resumenDeTarea(backlog[2]));
// 'Actualizar la web de reservas (Lucía, 14 h)'
console.log(resumenDeTarea());
// 'sin título (sin asignar, 0 h)'

console.log(filtrarBacklog(backlog, { estado: 'pendiente' }).length);          // 3
console.log(filtrarBacklog(backlog, { minimoHoras: 10 }).map((t) => t.id));    // [ 1, 3 ]
console.log(filtrarBacklog(backlog).length);                                    // 6
console.log(filtrarBacklog().length);                                           // 0

Los dos = {} son la clave para que las últimas llamadas no revienten. Fíjate también en que estado no lleva valor por defecto: aquí undefined significa «no filtres por estado», y eso es exactamente lo que comprueba !estado. Poner un defecto habría destruido esa semántica.

Ejercicio 2

const ESTADOS_VALIDOS = ['pendiente', 'en-curso', 'hecha'];

function cambiarEstado(tareas, id, nuevoEstado) {
  if (!ESTADOS_VALIDOS.includes(nuevoEstado)) {
    throw new Error(`Estado no válido: ${nuevoEstado}`);
  }
  if (!tareas.some((t) => t.id === id)) {
    throw new Error(`No hay ninguna tarea con id ${id}.`);
  }
  return tareas.map((t) => (t.id === id ? { ...t, estado: nuevoEstado } : t));
}

function asignarRevisor(tareas, id, revisor) {
  return tareas.map((t) => (t.id === id ? { ...t, revisor } : t));
}

function sinCamposInternos(tarea) {
  const { revisor, etiquetas, ...publica } = tarea;
  return publica;
}

const backlogV2 = cambiarEstado(backlog, 6, 'hecha');
console.log(backlogV2[5].estado);      // 'hecha'
console.log(backlog[5].estado);        // 'pendiente'   ✓

const backlogV3 = asignarRevisor(backlogV2, 2, 'Lucía');
console.log(backlogV3[1].revisor);     // 'Lucía'
console.log(backlogV2[1].revisor);     // undefined  ✓ la versión anterior no se ha tocado

console.log(Object.keys(sinCamposInternos(backlog[0])));
// [ 'id', 'titulo', 'responsable', 'prioridad', 'estado', 'horasEstimadas', 'fechaLimite' ]

try {
  cambiarEstado(backlog, 1, 'terminada');
} catch (error) {
  console.log(error.message);          // 'Estado no válido: terminada'
}

Tres detalles. En asignarRevisor, { ...t, revisor } usa la abreviatura de propiedad: la variable se llama igual que el campo. Las validaciones van antes de construir nada, siguiendo el principio de fail-fast de 02-05. Y some (04-05) es la forma correcta de comprobar la existencia sin recorrer la lista entera.

Ejercicio 3

const DEFECTOS = {
  prioridad: 'media',
  estado: 'pendiente',
  etiquetas: [],
  horasEstimadas: 1,
  revisor: null
};

function desdePlantilla(plantilla = {}, datos = {}) {
  return {
    ...DEFECTOS,                                  // 1. lo más genérico
    ...plantilla,                                 // 2. la plantilla pisa los defectos
    ...datos,                                     // 3. los datos concretos mandan
    etiquetas: [                                  // 4. las etiquetas se acumulan, no se pisan
      ...new Set([...(plantilla.etiquetas ?? []), ...(datos.etiquetas ?? [])])
    ]
  };
}

const PLANTILLA_SERIGRAFIA = { prioridad: 'media', etiquetas: ['serigrafía'], horasEstimadas: 4, revisor: 'Marta' };
const datos = { id: 9, titulo: 'Camisetas del curso de otoño', responsable: 'Iván', horasEstimadas: 6 };

const nueva = desdePlantilla(PLANTILLA_SERIGRAFIA, datos);
console.log(nueva);
// { prioridad: 'media', estado: 'pendiente', horasEstimadas: 6, revisor: 'Marta',
//   id: 9, titulo: 'Camisetas del curso de otoño', responsable: 'Iván',
//   etiquetas: [ 'serigrafía' ] }

nueva.etiquetas.push('camisetas');
console.log(PLANTILLA_SERIGRAFIA.etiquetas);   // [ 'serigrafía' ]   ✓ intacta

Lo esencial del ejercicio está en el punto 4. Si la función se hubiera limitado a { ...DEFECTOS, ...plantilla, ...datos }, el campo etiquetas del resultado sería el mismo array que el de PLANTILLA_SERIGRAFIA, y el push final habría contaminado la plantilla para todas las tareas futuras: el problema de la copia superficial del apartado 15. La solución ha sido desplegar explícitamente el array interior con [...] —creando uno nuevo— y, de paso, usar un Set para no duplicar etiquetas que vengan por las dos vías. Ese trabajo manual, nivel por nivel, es exactamente lo que la lección siguiente automatiza.

Observa también que horasEstimadas acaba siendo 6 y no 4: los datos concretos van los últimos y ganan, tal como dice la regla del orden.

Conclusión

Tienes la sintaxis completa. La desestructuración de objetos empareja por nombre: puedes renombrar con { titulo: t } —leyéndolo siempre como «propiedad a la izquierda, variable a la derecha»—, dar valores por defecto que solo se activan con undefined, combinar ambas cosas, y entrar en objetos anidados recordando que la variable intermedia no se crea. Su mejor uso son los parámetros: el objeto de opciones que en 03-03 se desmontaba línea a línea dentro del cuerpo ahora se declara en la firma, con crearTarea({ ... }) y estaVencida({ fechaLimite, estado }, hoy) documentándose solas, y siempre con el = {} que evita el TypeError al llamar sin argumentos.

Del otro lado, el operador ... en sus dos caras: rest cuando recoge (parámetros sobrantes, propiedades sin nombrar, const { id, ...resto } = tarea para quitar campos sin delete) y spread cuando reparte (copiar, combinar, sobrescribir, insertar en medio, pasar argumentos con Math.max(...horas)). Con la regla que lo gobierna todo: al combinar, gana el último, así que los valores por defecto van primero y los datos reales después. Y con ello has escrito el patrón que sostiene el desarrollo moderno: la actualización inmutable { ...tarea, estado: 'hecha' } y su versión sobre listas, tareas.map((t) => t.id === id ? { ...t, ...cambios } : t), que produce un backlog nuevo conservando por referencia las tareas que no han cambiado.

Pero has terminado chocando con un muro. El spread hace una copia superficial: cambiar copia.titulo es seguro, pero copia.etiquetas.push(...) modifica también el original, porque ambos objetos apuntan al mismo array. Hay un apaño manual, { ...tarea, etiquetas: [...tarea.etiquetas] }, y funciona mientras conozcas todos los niveles anidados; con el árbol de subtareas de profundidad desconocida, no hay apaño que valga. Necesitas una copia profunda de verdad. Eso, junto con la otra gran pregunta que aún no has resuelto —cómo se convierte el backlog en texto para poder guardarlo y recuperarlo— es el contenido de JSON y Copias de Objetos, la última lección del módulo.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados