En el capítulo de { ...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
statsanidado (conrango,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.
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 superficial —copia.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.
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.
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.
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.
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.
Paso 2: Que esté pulido
- Copias por niveles (el héroe y su stats) o con structuredClone.
- El original queda intacto: se ve igual antes y después.
- No mutas ningún dato de entrada.
Paso 3: Que sea excelente
- Un helper reutilizable centraliza la actualización anidada.
- Congelas la entrada con Object.freeze.
- Funciones puras y render separado del cálculo.
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.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL MEJOR — "pulido"
//
// Por qué mejora a OK:
// - Copia POR NIVELES: además del objeto, copia su stats. Así el stats de la
// copia es uno NUEVO, no la referencia compartida del original. Resultado:
// el original queda intacto (compruébalo en la consola).
// - (Alternativa igual de válida: structuredClone(heroe) y mutar la copia
// profunda. Aquí usamos spread por niveles porque solo cambiamos stats.)
// ════════════════════════════════════════════════════════════════════════════
// 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 el héroe Y su stats; pisa solo rango. El original no se toca.
function subirRango(heroe, nuevoRango) {
return { ...heroe, stats: { ...heroe.stats, rango: nuevoRango } };
}
// Copia el héroe y su stats, sumando 1 a partidas y a victorias. Sin mutar.
function registrarVictoria(heroe) {
return {
...heroe,
stats: {
...heroe.stats,
partidas: heroe.stats.partidas + 1,
victorias: heroe.stats.victorias + 1,
},
};
}
function mostrar() {
// original 1
const tracer = roster[0];
// original 2
const mercy = roster[1];
// copia ascendida
const ascendido = subirRango(tracer, "Gran Maestro");
// copia con una victoria más
const conVictoria = registrarVictoria(mercy);
console.log("Originales (intactos):");
// Tracer — Diamante: el original no cambió
console.log(tracer.nombre + ": " + tracer.stats.rango);
// Mercy — partidas y victorias originales, sin tocar
console.log(
mercy.nombre +
": " +
mercy.stats.partidas +
" partidas, " +
mercy.stats.victorias +
" victorias",
);
console.log("Copias:");
// ascendido — Gran Maestro
console.log(ascendido.nombre + ": " + ascendido.stats.rango);
// conVictoria — partidas y victorias incrementadas
console.log(
conVictoria.nombre +
": " +
conVictoria.stats.partidas +
" partidas, " +
conVictoria.stats.victorias +
" victorias",
);
}
mostrar(); Por qué es mejor que el anterior
- Copia POR NIVELES (el héroe y su stats): el stats de la copia es uno nuevo, así que el original queda intacto.
- Mismo resultado con structuredClone(heroe) y mutar la copia profunda; aquí basta el spread por niveles porque solo cambia stats.
- Su límite respecto a Excelente: repite el patrón de copia en cada función y no blinda la entrada.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL EXCELENTE — "óptimo"
//
// Por qué mejora a Mejor:
// - Object.freeze del roster de entrada: deja por escrito que NADA debe mutarlo
// y, en modo estricto, cualquier intento de mutación lanzaría en vez de pasar
// desapercibido. Los datos de origen son intocables.
// - Un único helper de actualización anidada, actualizarStats, que todas las
// operaciones reutilizan: la regla "copia el héroe y su stats" vive en UN sitio.
// - Funciones puras (misma entrada, misma salida, sin efectos); el mostrar solo
// presenta lo ya calculado: cálculo y presentación separados.
//
// Idea de fondo: la inmutabilidad no es copiar por copiar, es no estropear nunca
// los datos de origen. Un helper bien elegido evita repetir el patrón en cada función.
// ════════════════════════════════════════════════════════════════════════════
// Congelado: el roster de origen es de solo lectura.
const roster = Object.freeze([
{
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 },
},
]);
// Helper puro: devuelve una copia del héroe con su stats fusionado con `cambios`.
// Copia los dos niveles (héroe y stats); el original nunca se toca.
function actualizarStats(heroe, cambios) {
return { ...heroe, stats: { ...heroe.stats, ...cambios } };
}
// Las operaciones se apoyan en el helper: el "cómo se copia" no se repite.
function subirRango(heroe, nuevoRango) {
return actualizarStats(heroe, { rango: nuevoRango });
}
function registrarVictoria(heroe) {
return actualizarStats(heroe, {
partidas: heroe.stats.partidas + 1,
victorias: heroe.stats.victorias + 1,
});
}
// Presenta un héroe como una línea de texto. Pura: no toca nada de fuera.
function lineaHeroe(heroe) {
// destructuring (cap. 1)
const { rango, partidas, victorias } = heroe.stats;
return `${heroe.nombre} — ${rango}, ${partidas} partidas, ${victorias} victorias`;
}
function mostrar() {
// destructuring por posición
const [tracer, mercy] = roster;
const ascendido = subirRango(tracer, "Gran Maestro");
const conVictoria = registrarVictoria(mercy);
console.log("Originales (intactos, además congelados):");
// Tracer — Diamante, 120 partidas, 78 victorias
console.log(lineaHeroe(tracer));
// Mercy — Maestro, 200 partidas, 130 victorias
console.log(lineaHeroe(mercy));
console.log("Copias derivadas:");
// Tracer — Gran Maestro, 120 partidas, 78 victorias (rango cambiado, resto intacto)
console.log(lineaHeroe(ascendido));
// Mercy — Maestro, 201 partidas, 131 victorias (partidas y victorias +1)
console.log(lineaHeroe(conVictoria));
}
mostrar(); Por qué es mejor que el anterior
- Un único helper actualizarStats centraliza el 'copia el héroe y su stats': el patrón vive en un solo sitio.
- Object.freeze del roster de entrada: los datos de origen son intocables (en modo estricto, mutar lanzaría).
- Funciones puras y render que solo presenta lo ya calculado; reutiliza el destructuring del capítulo 1.