learning-front

Nivel 3 · JavaScript moderno y asíncrono

Copias: superficial vs profunda

Por qué { ...obj } copia un solo nivel y lo anidado se queda compartido, cómo hacer una copia profunda con structuredClone y congelar con Object.freeze.

En el capítulo de spread y rest aprendiste a copiar con { ...heroe } y [...lista], y lo usaste para “cambiar” un campo sin mutar el original. Funcionaba… mientras el campo que tocabas estuviera en el primer nivel. Pero los datos reales tienen capas: un héroe con un objeto stats dentro, una respuesta de servidor con objetos dentro de arrays dentro de objetos. Ahí el spread esconde una trampa que conviene ver de frente, porque es una de las fuentes de bugs más comunes al manejar estado.

Seguimos con el Overwatch Team Builder. El héroe de los ejemplos tiene un objeto stats anidado (con rango, mvp, partidas…). Ese anidamiento es justo lo que pone a prueba la copia.

El spread copia un solo nivel#

Cuando haces { ...heroe }, JavaScript copia las propiedades del primer nivel a un objeto nuevo. Pero si una propiedad es a su vez un objeto (como stats), no se duplica ese objeto: se copia la referencia. Es decir, la copia y el original acaban apuntando al mismo stats.

javascript
const heroe = {
  nombre: "Tracer",
  // un objeto DENTRO del héroe
  stats: { rango: "Diamante", mvp: 12 },
};

// Copia superficial: el primer nivel sí se duplica; stats NO.
const copia = { ...heroe };

// primer nivel: cambia solo en la copia
copia.nombre = "Eco";
// anidado: stats es compartido → cambia en AMBOS
copia.stats.rango = "Maestro";

// Demuestra que el primer nivel sí quedó aislado: nombre es independiente en la copia.
// Tracer   (el original conserva su nombre)
console.log(heroe.nombre);
// Demuestra la trampa del spread superficial: stats se compartía, así que mutarlo
// en la copia también mutó el original (son el mismo objeto en memoria).
// Maestro  (¡el original SÍ cambió de rango!)
console.log(heroe.stats.rango);

Eso es lo que significa que el spread es una copia superficial (en inglés, shallow): clona la capa de arriba, pero lo que hay más adentro se comparte. copia.stats y heroe.stats son el mismo objeto en memoria; tocar uno toca el otro.

Por qué esto importa (y mucho)#

Recuerda que en el capítulo anterior dijimos que { ...estado, campo: nuevo } es la forma de actualizar el estado sin mutarlo. Eso es cierto para campos de primer nivel. Pero si mutas algo anidado a través de una copia superficialcopia.stats.rango = "Maestro"—, estás mutando el estado original sin querer. Lo verás en el nivel de React: ese error provoca bugs difíciles de rastrear, porque la interfaz no se refresca cuando debería, o cambia un dato que creías intacto. Por eso necesitas saber cuándo una copia es suficiente y cuándo no.

Copia profunda: structuredClone#

Si quieres una copia de verdad independiente —que no comparta ninguna referencia con el original, en ningún nivel—, necesitas una copia profunda (deep). El navegador trae una función para eso: structuredClone.

javascript
const heroe = { nombre: "Mercy", stats: { rango: "Maestro", mvp: 21 } };

// Copia profunda: duplica TODOS los niveles, también stats.
const copia = structuredClone(heroe);

// toca solo la copia
copia.stats.rango = "Gran Maestro";

// Maestro      (el original, intacto de verdad)
console.log(heroe.stats.rango);
// Gran Maestro (la copia, independiente)
console.log(copia.stats.rango);

Quizá hayas visto por ahí el truco JSON.parse(JSON.stringify(obj)) para clonar en profundidad. Funciona a medias, pero pierde cosas por el camino: las funciones desaparecen, las fechas se convierten en texto y los undefined se esfuman. structuredClone es la herramienta moderna y correcta para esto; usa JSON para clonar solo si sabes que el objeto es puro texto y números.

structuredClone también tiene un límite: no sabe clonar funciones (si el objeto contiene una, lanza un error). A cambio, sí copia bien fechas, Map y Set, donde el truco de JSON falla. Para los datos típicos de una app —objetos y arrays con texto, números y booleanos— te sirve de sobra.

Actualización anidada sin clonar todo#

Clonar en profundidad cada vez que cambias un campo es caro si el objeto es grande: copias todo para tocar una cosa. Cuando solo necesitas “cambiar un dato anidado sin mutar”, hay una vía más fina: copiar con spread cada nivel del camino hasta el campo que cambias.

javascript
const heroe = { nombre: "Tracer", stats: { rango: "Diamante", mvp: 12 } };

// Copia el héroe Y su stats; pisa solo rango. Nada del original se toca.
const ascendido = {
  // copia los campos del héroe (nombre, stats)
  ...heroe,
  stats: {
    // copia los campos de stats (rango, mvp)
    ...heroe.stats,
    // y pisa rango con el valor nuevo
    rango: "Maestro",
  },
};

// Diamante (intacto)
console.log(heroe.stats.rango);
// Maestro  (la copia, con el cambio)
console.log(ascendido.stats.rango);

Esta es la forma que verás en React para actualizar estado anidado: spread por niveles, pisando solo lo que cambia. Es inmutable (no toca el original) y barata (no clona ramas que no necesitas).

Object.freeze: prohibir las mutaciones#

A veces no basta con tener cuidado: quieres que un objeto sea de solo lectura, que nadie pueda cambiarlo ni por error. Object.freeze lo congela: ya no se le pueden cambiar, añadir ni quitar propiedades. Funciona igual con arrays (que en JavaScript son objetos): Object.freeze([...]) impide que se añadan, quiten o reasignen posiciones del primer nivel del array.

javascript
const config = Object.freeze({ region: "Europa", modo: "Competitivo" });

// en modo estricto LANZA; si no, se ignora en silencio
config.modo = "Amistoso";

// Competitivo  (no se pudo cambiar)
console.log(config.modo);

Un aviso importante, porque es el mismo patrón de antes: Object.freeze también es superficial. Congela el primer nivel, pero los objetos anidados siguen siendo mutables.

javascript
const heroe = Object.freeze({ nombre: "Tracer", stats: { rango: "Diamante" } });

// bloqueado: heroe está congelado
heroe.nombre = "Eco";
// NO bloqueado: stats es un objeto aparte, sin congelar
heroe.stats.rango = "Maestro";

// Tracer  (congelado)
console.log(heroe.nombre);
// Maestro (el anidado sí cambió)
console.log(heroe.stats.rango);

Para congelar de verdad en profundidad habría que recorrer el objeto y congelar cada nivel. La idea que se repite en todo el capítulo: superficial = un nivel; profundo = todos. Saber en cuál estás es lo que evita el bug.

Pruébalo tú#

Edita el código y pulsa Ejecutar (o Ctrl+Enter) para ver la consola. Empieza por la última línea: cambia copia.stats.mvp y comprueba si el original también cambia (lo hará: comparten stats).

Comprueba lo que sabes#

Pregunta 1 de 5

Con const c = { ...heroe } y heroe.stats anidado, ¿qué copia el spread?

Tu turno#

Leerlo no es lo mismo que saber escribirlo. Resuélvelo aquí: edita solucion.js para subir el rango de un héroe y registrar una victoria sin mutar los originales, y muéstralos por la consola para comprobarlo. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un nivel al siguiente.

Ejercicio · en esta página

Actualiza datos anidados sin romper el original

A partir del roster (héroes con un objeto stats anidado), escribe dos actualizaciones inmutables: subir el rango de un héroe y registrar una victoria (partidas y victorias +1). Muestra originales y copias por la consola para comprobar que los originales no cambian.

Paso 1: Que funcione (y que veas el bug)

  • Subes el rango y registras la victoria.
  • Originales y copias se ven en la consola.
  • Nota: este tier muestra el bug a propósito. La copia superficial estropea los originales: compruébalo en la consola. Los tiers siguientes lo arreglan.
Ver soluciones
// ════════════════════════════════════════════════════════════════════════════
// NIVEL OK — "funciona"
//
// Copia con spread... pero solo el PRIMER nivel. Como stats es la MISMA
// referencia en la copia y en el original, al cambiar el rango o los contadores
// de la copia, el original TAMBIÉN cambia. Muestra algo por la consola, sí,
// pero corrompe los datos de entrada: es justo el bug que este capítulo enseña
// a evitar.
//
// Compruébalo en la consola: tras "ascender" a Tracer, el original ya no es
// Diamante.
// ════════════════════════════════════════════════════════════════════════════

// Copia autocontenida de los datos para ejecutar esta solución suelta.
const roster = [
  {
    nombre: "Tracer",
    rol: "Daño",
    stats: { rango: "Diamante", partidas: 120, victorias: 78 },
  },
  {
    nombre: "Mercy",
    rol: "Apoyo",
    stats: { rango: "Maestro", partidas: 200, victorias: 130 },
  },
  {
    nombre: "Genji",
    rol: "Daño",
    stats: { rango: "Oro", partidas: 150, victorias: 72 },
  },
];

// Copia superficial: copia.stats === heroe.stats (la MISMA referencia).
function subirRango(heroe, nuevoRango) {
  // solo copia el primer nivel
  const copia = { ...heroe };
  // muta el stats COMPARTIDO → toca el original
  copia.stats.rango = nuevoRango;
  return copia;
}

function registrarVictoria(heroe) {
  // misma trampa: stats sigue compartido
  const copia = { ...heroe };
  copia.stats.partidas = copia.stats.partidas + 1;
  copia.stats.victorias = copia.stats.victorias + 1;
  return copia;
}

function mostrar() {
  // el original
  const tracer = roster[0];
  // "copia" ascendida
  const ascendido = subirRango(tracer, "Gran Maestro");
  // "copia" con una victoria más
  const conVictoria = registrarVictoria(roster[1]);

  // OJO: los originales YA están tocados (tracer ya no es Diamante; Mercy ya
  // tiene una partida y una victoria de más). La copia superficial estropeó
  // la entrada. Compruébalo en la consola.
  console.log("Originales (deberían seguir intactos):");
  // Tracer — rango: debería ser Diamante, pero el spread superficial lo tocó
  console.log(tracer.nombre + ": " + tracer.stats.rango);
  // Mercy — partidas/victorias: deberían ser las originales, pero también cambiaron
  console.log(
    roster[1].nombre +
      ": " +
      roster[1].stats.partidas +
      " partidas, " +
      roster[1].stats.victorias +
      " victorias",
  );
  console.log("Copias:");
  // ascendido — Gran Maestro (el cambio que queríamos)
  console.log(ascendido.nombre + ": " + ascendido.stats.rango);
  // conVictoria — partidas y victorias incrementadas
  console.log(
    conVictoria.nombre +
      ": " +
      conVictoria.stats.partidas +
      " partidas, " +
      conVictoria.stats.victorias +
      " victorias",
  );
  console.log(
    "Mira los originales: cambiaron. La copia superficial los estropeó.",
  );
}

mostrar();

Por qué este nivel

  • Copia con spread, pero solo el primer nivel: como stats es la misma referencia, ascender al héroe muta también el original.
  • Funciona y pinta, que es el primer requisito.
  • Su límite: corrompe los datos de entrada (Tracer deja de ser Diamante). Es justo el bug que este capítulo enseña a evitar.