wandres.dev
REACTIVIDAD PROFUNDA · Proxies y el estado anidado

La trampa de la desestructuración

Por qué desestructurar un objeto reactivo rompe la conexión, cuál es el mecanismo exacto del fallo, y las cuatro soluciones que ofrecen los motores reales, incluida la del compilador.

⏱ 16 min

De todos los errores que se cometen con reactividad de grano fino, este es el más universal: desestructurar un objeto reactivo y descubrir que lo desestructurado ya no reacciona. No es un fallo del motor ni una excepción rara, es una consecuencia inevitable de cómo funciona el lenguaje. Entender el mecanismo exacto explica de paso por qué todos los motores tienen alguna forma de esquivarlo y por qué ninguna es del todo satisfactoria.

🎯 Al terminar esta lección sabrás
  • Explicar el mecanismo exacto por el que la desestructuración rompe la reactividad.
  • Reconocer las cinco formas que tiene el mismo error.
  • Aplicar las cuatro soluciones y sus contrapartidas.
  • Entender por qué solo un compilador puede resolverlo del todo.

El mecanismo

La reactividad de grano fino se apoya en que la lectura ocurra dentro del ámbito rastreado y en el momento correcto. Desestructurar hace lo contrario: lee ahora, una vez, y guarda el resultado en una variable local.

const estado = reactivo({ nombre: 'Ana', edad: 30 });

const { nombre } = estado;      // dispara get UNA vez, en esta linea
efecto(() => pintar(nombre));   // pinta una cadena, no lee nada reactivo
estado.nombre = 'Eva';           // el efecto no se entera

La línea de la desestructuración sí dispara la trampa. Lo que no ocurre es que la variable nombre conserve ninguna conexión: es una cadena, un valor primitivo, sin ninguna relación con el objeto del que salió. La arista se tejió con quien estuviera rastreando en esa línea —probablemente nadie— y no con el efecto.

Dicho con la formulación general del nivel 3: has leído el valor en vez de conservar la manera de leerlo. Y como en JavaScript un valor primitivo no puede llevar consigo ninguna identidad, es imposible que sea de otra manera.

flowchart TB
A[estado punto nombre] --> B[la trampa get se dispara]
B --> C[devuelve la cadena Ana]
C --> D[se guarda en una variable local]
D --> E[la variable es una cadena sin conexion]
E --> F[leerla dentro del efecto no teje nada]
style A fill:#89b4fa,color:#11111b
style B fill:#cba6f7,color:#11111b
style C fill:#f9e2af,color:#11111b
style D fill:#f9e2af,color:#11111b
style E fill:#f38ba8,color:#11111b
style F fill:#f38ba8,color:#11111b

Las cinco formas del mismo error

El error tiene disfraces y conviene reconocerlos todos, porque son el mismo mecanismo.

Desestructuración explícita, la que acabamos de ver.

Asignación a una variable local. Idéntica, sin la sintaxis.

const nombre = estado.nombre;   // mismo problema

Pasar el valor como parámetro. Al llamar, se evalúa la expresión y se pasa el resultado.

mostrar(estado.nombre);         // la funcion recibe una cadena

Propagar en un objeto nuevo. El operador de propagación lee todas las propiedades en ese instante.

const copia = { ...estado };    // copia plana de los valores actuales

Guardar en una estructura. Meter el valor en un array o en un Map congela igualmente.

const valores = [estado.a, estado.b];

Los cinco comparten la causa: se evalúa la expresión de acceso fuera del ámbito rastreado, o se evalúa y se guarda el resultado. La regla de detección es sencilla: si en el código aparece un acceso a propiedad reactiva que no está dentro del cuerpo de una derivación o de un efecto, hay riesgo.

Las cuatro soluciones

Solución uno: acceder dentro del ámbito. La más simple y casi siempre la correcta. No extraigas: accede donde lo necesitas.

efecto(() => pintar(estado.nombre));   // el acceso ocurre dentro

Con funciones auxiliares, pasa el objeto en vez del valor.

function mostrar(estado) { pintar(estado.nombre); }   // el acceso ocurre dentro del efecto
efecto(() => mostrar(estado));

Solución dos: envolver en una función. Convierte el valor en una manera de obtenerlo.

const nombre = () => estado.nombre;
efecto(() => pintar(nombre()));        // el acceso ocurre al llamar, dentro del efecto

Es lo que hacen las utilidades de los motores para convertir una propiedad en una referencia independiente. Vue tiene toRef para una propiedad y toRefs para todas, que devuelven objetos con getter en lugar de valores planos. La desestructuración de esos objetos sí conserva la reactividad, porque lo que se extrae no es el valor sino un contenedor con getter.

const { nombre, edad } = toRefs(estado);   // nombre es una ref, no una cadena
efecto(() => pintar(nombre.value));

Solución tres: pasar getters. Cuando el destino es una función que recibe configuración, pásale funciones en lugar de valores.

crearWidget({ get titulo() { return estado.titulo; } });

Es verboso y es exactamente lo que hace un compilador por ti cuando transforma las propiedades de un componente en un objeto con getters.

Solución cuatro: que lo resuelva el compilador. Es la única que elimina el problema en vez de rodearlo, y es de lo que trata el nivel 11: un compilador puede reescribir const { nombre } = props en accesos a props.nombre en cada punto de uso. Svelte lo hace con sus runes, y el compilador de Solid transforma las propiedades de componente en un objeto con getters precisamente por esto.

⚠️
Ninguna solucion es gratis

Acceder dentro del ámbito obliga a arrastrar el objeto por toda la cadena de llamadas, lo que acopla las funciones auxiliares a la estructura del estado. Envolver en funciones produce código con más ruido. Pasar getters es verboso. Y el compilador introduce una diferencia entre lo que escribes y lo que se ejecuta, con el coste de depurabilidad que eso conlleva. Que existan cuatro soluciones y ninguna sea claramente la mejor indica que el problema es de fondo, no de implementación.

Es el problema de la referencia frente al valor, con otro nombre

Debajo de esto hay una tensión clásica que trasciende la reactividad. Un sistema reactivo necesita referencias: algo que apunte al valor y que permita leerlo de nuevo más tarde. JavaScript, para los primitivos, solo tiene valores: una vez que sacas un número o una cadena de su contenedor, no hay forma de saber de dónde salió. Cualquier motor reactivo en cualquier lenguaje sin referencias de primera clase se encuentra este mismo muro, y todos resuelven igual: mantener el valor dentro de un contenedor y obligar a que el acceso pase por él. La diferencia entre los motores está solo en cuánto de visible es ese contenedor. Solid lo hace máximamente visible: la señal es una función y tienes que llamarla, con lo que la trampa es difícil de pisar porque desestructurar una función no hace nada raro. Vue lo hace parcialmente visible: .value en una ref y transparente en un objeto reactive, lo que explica que la trampa aparezca sobre todo con reactive. Svelte lo hace invisible con el compilador, con lo que la trampa desaparece de la sintaxis y reaparece en las fronteras que el compilador no controla. Y aquí está la conclusión que ordena todo el nivel: hay una relación inversa exacta entre lo ergonómica que es la sintaxis y lo evidente que resulta la trampa. No es un defecto de ninguno de los tres diseños; es una consecuencia de que el problema esté en la semántica del lenguaje y no en la librería. Elegir un motor es, en buena medida, elegir en qué punto de ese eje quieres estar.

Detectarlo antes de que duela

Como el patrón es sintáctico, se puede detectar. Los motores maduros publican reglas de lint que marcan la desestructuración de objetos reactivos conocidos, y en desarrollo emiten avisos cuando detectan un acceso a una propiedad reactiva fuera de todo ámbito rastreado.

Si escribes tu propio motor, la comprobación barata es esta: en la trampa get, si el observador actual es nulo y estamos en modo de desarrollo, registra un aviso con la traza. Produce falsos positivos —hay lecturas legítimas fuera de ámbito— pero atrapa la mayoría de los casos reales.

get(obj, clave, receptor) {
  if (DESARROLLO && !Observador) {
    console.warn(`lectura reactiva de ${String(clave)} fuera de un ambito rastreado`);
  }
  seguir(fuenteDe(clave));
  return reactivo(Reflect.get(obj, clave, receptor));
}
⚔️ Reproduce las cinco formas
  1. Escribe un caso mínimo para cada una de las cinco formas del error y comprueba que ninguna produce un fallo visible.
  2. Aplica la solución de acceder dentro del ámbito a las cinco y verifica que todas se arreglan.
  3. Implementa una utilidad que convierta una propiedad en un objeto con getter y comprueba que su desestructuración sí funciona.
  4. Añade el aviso de desarrollo a tu proxy y mide cuántos falsos positivos produce en código real.