Sincronizar estados con efectos: el olor y su cura
El antipatrón que más código inútil ha generado en la historia de las interfaces reactivas: el efecto cuyo único trabajo es copiar una prop o un estado ajeno al estado local. Esta lección disecciona por qué ese efecto siempre llega tarde por construcción, qué le ocurre exactamente al usuario durante el render intermedio, y por qué el olor no está en el efecto sino en la variable de estado que lo obliga a existir. Presenta la escalera de curas en el orden en que hay que probarlas —derivar, remontar por identidad, ajustar durante el render y solo al final elevar el control—, explica el caso del valor inicial que engaña a todo el mundo, y delimita el único territorio donde un efecto de sincronización es legítimo: la frontera con sistemas que el framework no gobierna.
Hay un fragmento de código que aparece en prácticamente todas las bases de código reactivas del mundo y que, con muy pocas excepciones, no debería existir en ninguna: el efecto que observa una prop y escribe su valor en una variable de estado local. Quien lo escribe suele tener una motivación impecable. Necesita poder modificar ese valor sin tocar al padre, o quiere que el componente conserve una versión editable de algo que llega de fuera, o simplemente ha aprendido que cuando un dato viene de arriba y hay que reaccionar a él, el instrumento es un efecto. El problema es que el instrumento equivocado no produce un resultado ligeramente peor, produce una clase entera de fallos nueva: el valor local va siempre un render por detrás del que le dio origen, existe un instante perfectamente observable en el que la pantalla muestra dos datos incoherentes entre sí, y el hecho pasa a tener dos custodios con lo que la lección uno de este nivel ya nos dijo que ocurre a continuación. Este antipatrón merece lección propia no por ser el más grave, sino por ser el más frecuente y el que peor se enseña: casi nadie lo reconoce como duplicación, porque el efecto crea la ilusión de que la copia se mantiene sola.
- Explicar por qué el efecto de sincronización llega tarde por construcción y qué ve el usuario durante el render intermedio.
- Reconocer el olor real: no es el efecto, es la variable de estado que obliga a escribirlo.
- Aplicar la escalera de curas en orden: derivar, remontar por identidad, ajustar durante el render y elevar el control.
- Delimitar el uso legítimo del efecto: sincronizar con sistemas externos, no con otras partes del propio estado.
Siempre un render tarde
El efecto se ejecuta después de pintar. Existe un fotograma en el que la prop nueva convive con el estado local viejo.
La copia que finge cuidarse sola
El efecto da la sensación de mantener la copia sincronizada, y por eso oculta que sigue habiendo dos custodios del mismo hecho.
Cuatro curas, en orden
Derivar, remontar con identidad, ajustar durante el render, elevar el control. La primera que sirva es la correcta.
Por qué llega tarde: la secuencia exacta y lo que ve el usuario
La razón por la que este patrón falla no es una cuestión de opinión ni de estilo, sino una consecuencia directa del orden de ejecución. Cuando la prop cambia, el framework renderiza el componente con el valor nuevo de la prop y el valor viejo del estado local, calcula el árbol resultante y lo pinta. Solo después de pintar ejecuta los efectos, y es entonces cuando el efecto llama al modificador, lo que programa un segundo render, que vuelve a calcular el árbol y a pintarlo, esta vez ya coherente. Entre el primer pintado y el segundo hay un fotograma completo en el que la interfaz muestra un estado que no corresponde a ningún estado válido del dominio: la cabecera dice un usuario y el formulario muestra los datos del anterior.
// Antipatron: copiar la prop al estado local mediante un efecto.
function PanelUsuario({ usuario }: { usuario: Usuario }) {
const [nombre, setNombre] = useState(usuario.nombre)
useEffect(() => {
setNombre(usuario.nombre) // llega despues de pintar: un render de incoherencia
}, [usuario.nombre])
return <input value={nombre} onChange={e => setNombre(e.target.value)} />
}
Ese fotograma no es una anécdota teórica. En listas maestras con panel de detalle, donde el usuario cambia de elemento con las flechas del teclado a gran velocidad, el resultado es un parpadeo visible del contenido anterior en cada pulsación. Si el valor alimenta un cálculo posterior, la incoherencia se propaga: el precio se recalcula con la cantidad vieja durante un fotograma y algún usuario lo verá. Y si el efecto escribe un estado del que depende otro efecto, has construido una cascada en la que cada eslabón cuesta un render adicional, con lo que la latencia percibida crece linealmente con la profundidad de la cadena.
Hay un segundo problema, más sutil y de peores consecuencias, que aparece cuando el usuario interviene mientras la cadena está a medias. El estado local es editable —para eso se creó— de modo que existe una carrera real entre la escritura del usuario y la escritura del efecto. Si la prop cambia justo después de que el usuario teclee, el efecto pisará lo que acaba de escribir y perderá su entrada sin ningún mensaje. Este es el bug que se reporta como se me borra lo que escribo a veces, es endiabladamente difícil de reproducir en un entorno de desarrollo tranquilo, y su causa raíz no está en ninguna condición de carrera exótica sino en la decisión de tener dos escritores para un mismo campo.
flowchart TD P[la prop cambia] --> R1[render uno: prop nueva y estado local viejo] R1 --> V[pintado incoherente visible] V --> E[se ejecuta el efecto de sincronizacion] E --> S[escritura del estado local] S --> R2[render dos: ya coherente] R2 --> C[coste: un fotograma malo y un render extra] P --> A1[cura uno: derivar sin estado] P --> A2[cura dos: remontar por identidad] P --> A3[cura tres: ajustar durante el render] P --> A4[cura cuatro: elevar el control] style V fill:#f38ba8,color:#11111b style C fill:#f38ba8,color:#11111b style A1 fill:#a6e3a1,color:#11111b
El olor no está en el efecto: está en la variable que lo obliga
El error de diagnóstico más extendido consiste en mirar el efecto y preguntarse cómo escribirlo mejor: si añadir una comparación previa para evitar escrituras redundantes, si ajustar el array de dependencias, si mover la escritura a un efecto de disposición para que ocurra antes del pintado. Ninguna de esas variantes ataca el problema, porque el efecto no es la enfermedad sino el síntoma. La enfermedad es la variable de estado local, que existe para representar un hecho del que ya hay un custodio arriba, y el efecto es el andamio que alguien tuvo que construir para sostener esa decisión.
Vale la pena examinar por qué fracasan las variantes que la gente intenta, porque entenderlo ahorra recorrerlas una por una. Añadir una comparación previa para no escribir si el valor no ha cambiado reduce el número de renders extra pero no elimina el fotograma incoherente en el caso que importa, que es justamente aquel en que el valor sí ha cambiado. Mover la escritura a un efecto que se ejecuta antes del pintado elimina el fotograma pero conserva los dos custodios, de modo que la carrera con la escritura del usuario sigue intacta y el bug difícil permanece. Y afinar el array de dependencias no toca ninguna de las dos cosas: cambia cuándo se ejecuta el efecto, no el hecho de que exista una copia que alguien debe mantener. Las tres variantes son mejoras marginales sobre una decisión que no debía haberse tomado.
La consecuencia práctica de este cambio de foco es que la pregunta correcta nunca es cómo sincronizo mejor, sino por qué existe esta variable. Y la respuesta, casi siempre, es una de estas cuatro: porque necesito leerla transformada, porque necesito reiniciarla cuando cambia el contexto, porque necesito ajustarla en respuesta a un cambio concreto, o porque necesito que el usuario pueda editarla. Cada una de esas cuatro respuestas tiene una cura distinta, y probarlas en orden es lo que evita construir la solución cara cuando bastaba la barata.
Lee el cuerpo del efecto y pregúntate si todo lo que hace es llamar a un modificador de estado con un valor calculado a partir de props o de otro estado. Si la respuesta es sí, y no hay ninguna llamada a un sistema externo —red, almacenamiento, temporizador, oyente, API del navegador— entonces ese efecto no tiene nada que sincronizar con el mundo: está sincronizando el programa consigo mismo, que es exactamente el trabajo que el framework ya hace en cada render. Ese efecto siempre puede eliminarse, y el debate solo versa sobre cuál de las cuatro curas aplicar.
La escalera de curas, en el orden en que hay que probarlas
La primera cura es derivar, y resuelve el caso de leer el valor transformado. Si lo único que hace el estado local es contener una versión calculada de la prop, no necesitas estado en absoluto: calcula la expresión durante el render. Esta cura elimina el efecto, la variable, el render extra y el fotograma incoherente de una sola vez, y es aplicable con mucha más frecuencia de lo que la gente supone, porque la costumbre de asociar cualquier valor cambiante con una variable de estado está muy arraigada.
La segunda cura es remontar por identidad, y resuelve el caso de reiniciar cuando cambia el contexto. Cuando un formulario debe olvidar todo lo escrito porque el usuario ha pasado a editar otro registro, no hace falta ningún efecto que vaya reponiendo campo por campo: basta con decirle al framework que el componente que edita el registro A y el que edita el registro B son componentes distintos, dándoles una identidad distinta. El framework destruye el primero con todo su estado y monta el segundo desde cero, en el mismo render, sin fotograma intermedio y sin una sola línea de sincronización.
// Cura uno: derivar. No hay estado, no hay efecto, no hay incoherencia.
const iniciales = `${usuario.nombre[0]}${usuario.apellido[0]}`.toUpperCase()
// Cura dos: identidad. Cambiar la clave remonta el subarbol y reinicia su estado.
<FormularioPerfil key={usuario.id} usuario={usuario} />
La tercera cura es ajustar durante el render, y resuelve el caso raro pero real en el que solo un fragmento del estado debe corregirse cuando cambia una entrada concreta, conservando el resto. En lugar de un efecto posterior al pintado, se guarda el valor anterior de la entrada en una variable de estado auxiliar y, durante el render, se compara con el actual; si difieren, se escribe el estado inmediatamente. El framework detecta esa escritura antes de pintar, descarta el árbol a medio calcular y vuelve a renderizar sin haber mostrado nada incoherente. El coste es un render adicional, igual que con el efecto, pero desaparece el fotograma malo y desaparece la carrera con el usuario.
La cuarta cura es elevar el control, y resuelve el caso en el que el usuario debe poder editar el valor y el padre debe enterarse. Aquí el diagnóstico correcto es que el estado nunca debió estar abajo: el hecho pertenece al padre o a un ancestro común, y el hijo debe recibir el valor y un manejador para proponer cambios. Es la cura más invasiva y por eso va la última, pero también la única que elimina de raíz la existencia de dos custodios en lugar de administrarla.
// Cura tres: ajuste durante el render, sin efecto y sin fotograma incoherente.
const [previo, setPrevio] = useState(seleccionId)
if (previo !== seleccionId) {
setPrevio(seleccionId)
setOrden('reciente') // se aplica antes del pintado
}
// Cura cuatro: elevar. Un solo custodio, arriba; el hijo propone, no decide.
function CampoNombre({ valor, alCambiar }: { valor: string; alCambiar: (v: string) => void }) {
return <input value={valor} onChange={e => alCambiar(e.target.value)} />
}
Un detalle práctico decide si la escalera funciona: hay que probarla en orden y detenerse en el primer escalón que resuelva el caso. La tentación de saltar directamente a la cuarta cura es fuerte porque suena a la más arquitectónica y por tanto a la más seria, pero elevar un valor que solo consume un subárbol es el antipatrón de la lección cuatro de este nivel, y cambiar un error por otro no es progreso. La escalera está ordenada por coste creciente precisamente para que la solución barata se pruebe primero, y en la práctica las dos primeras curas resuelven cerca de tres de cada cuatro casos reales.
El único territorio legítimo: la frontera con lo que el framework no gobierna
Terminar la lección sin delimitar el uso correcto del efecto dejaría el consejo incompleto y produciría la sobrecorrección típica: equipos que empiezan a mirar con recelo cualquier efecto y acaban escribiendo maniobras extrañas para evitarlos donde eran la herramienta adecuada. El criterio es nítido y no admite zona gris. Un efecto es el puente entre el grafo reactivo y todo lo que queda fuera de él, y por tanto es la herramienta correcta exactamente cuando el otro extremo del puente no es estado del propio programa.
Pertenecen a ese territorio las suscripciones a fuentes externas, los oyentes de eventos del navegador, los temporizadores, el envío de peticiones a la red, la lectura y escritura del almacenamiento local, la manipulación imperativa de nodos que el framework no controla, la integración con bibliotecas de terceros que mantienen su propio estado interno, la sincronización con el título del documento o con el foco, y la instrumentación analítica. En todos estos casos hay algo al otro lado que no participa del orden de recálculo, y por eso hay que ir a buscarlo después de que ese orden haya terminado.
// Uso legitimo: el otro extremo del puente esta fuera del programa.
useEffect(() => {
const canal = new BroadcastChannel('sesion')
canal.addEventListener('message', alMensajeDeOtraPestana)
return () => { canal.removeEventListener('message', alMensajeDeOtraPestana); canal.close() }
}, [])
// Uso ilegitimo: ambos extremos del puente estan dentro del programa.
useEffect(() => { setResumen(construirResumen(lineas)) }, [lineas])
Existe además un matiz que hay que conocer porque genera discusiones improductivas: la sincronización hacia fuera sí es legítima aunque su origen sea estado. Escribir en el almacenamiento local cada vez que cambian las preferencias es un efecto correcto, porque el destino es un sistema externo. Lo que nunca es legítimo es la sincronización hacia dentro entre dos piezas del propio estado, y por eso la prueba práctica se formula mirando el destino de la escritura y no su origen: si el efecto termina llamando a un modificador de estado y nada más, sobra.
Existe un caso que parece una excepción legítima y no lo es: pasar una prop como valor inicial de un estado. Se escribe con toda naturalidad, porque el nombre del argumento dice inicial y eso suena a una sola vez. El problema es que el framework lo interpreta literalmente: usa ese valor en el primer render de esa instancia y lo ignora para siempre después. Si el componente permanece montado y la prop cambia —porque el usuario selecciona otro elemento de la lista y el árbol se reutiliza— el estado conserva el valor del elemento anterior y no hay ningún aviso. Es en este punto exacto donde nace el efecto de sincronización: alguien detecta que el valor no se actualiza, deduce que hay que reaccionar al cambio y añade el efecto que copia la prop, cerrando el círculo del antipatrón. La lectura correcta del síntoma es otra. Que el valor inicial no se actualice no es un defecto que haya que parchear: es la definición de valor inicial funcionando exactamente como se documentó. Lo que hay que decidir es si el componente debe reiniciarse por completo cuando cambia el contexto, en cuyo caso la respuesta es la identidad, o si el hecho pertenece al padre, en cuyo caso la respuesta es elevar el control. Añadir un efecto es la única de las tres opciones que no responde a la pregunta.
Vale la pena entender qué es exactamente un efecto en la teoría del modelo reactivo, porque el uso indebido se vuelve evidente en cuanto se ve. Todo sistema reactivo mantiene un grafo dirigido donde los nodos fuente son el estado, los nodos intermedios son derivaciones puras y las hojas son salidas. El motor garantiza que, cuando una fuente cambia, todos los nodos alcanzables desde ella se recalculan en orden topológico antes de producirse ninguna salida observable, y esa garantía es lo que hace que el sistema sea coherente por construcción: nunca existe un instante público en el que un derivado contradiga a su fuente. Un efecto vive fuera de ese contrato por definición, porque su cometido es tocar aquello que el grafo no puede modelar: la red, el disco, el reloj, el árbol de nodos del navegador, un sistema de terceros. Se ejecuta al final, tras el punto de coherencia, precisamente porque el mundo exterior no participa del orden topológico. Cuando escribes un efecto que lee estado y escribe estado, estás cogiendo una arista que pertenecía al interior del grafo y sacándola fuera para volver a meterla por la puerta de las fuentes. El motor ya no puede ordenarla junto con las demás, porque para él es una escritura nueva y no una derivación; el resultado es que has convertido lo que era una relación resuelta en orden topológico en un ciclo de retroalimentación resuelto en varios pasos de reloj, con un estado público incoherente en cada paso intermedio. Y esto explica algo que de otro modo parece arbitrario: por qué la comunidad de todos los frameworks reactivos, con arquitecturas muy distintas, ha convergido en el mismo consejo. No es una preferencia de estilo compartida por casualidad. Es que en los cuatro modelos —árbol de componentes, signals de grano fino, observables o proxies— el efecto ocupa la misma posición estructural, la de puente hacia el exterior, y usarlo para relacionar dos partes del interior degrada un grafo que el motor sabía resolver a una máquina de estados que ahora mantienes tú a mano, sin herramientas y sin garantías.
- Busca en tu código todos los efectos cuyo cuerpo se reduzca a llamar a un modificador de estado y haz una lista.
- Descarta de la lista los que tocan red, almacenamiento, temporizadores u oyentes: esos son puentes legítimos hacia fuera.
- Para cada superviviente, clasifica su motivo real en uno de los cuatro: leer transformado, reiniciar, ajustar o editar.
- Aplica la cura correspondiente al más simple de ellos y comprueba en el perfilador que desaparece el segundo render.
- Localiza un componente que reciba una prop como valor inicial y decide si le corresponde una clave de identidad o elevar el control.
- Reproduce a propósito la carrera entre la escritura del usuario y la del efecto en uno de los casos, y verifica que la cura la elimina.