Saltar al contenido

useSyncExternalStore y el tearing

¿Por qué no alcanza con un useEffect para leer un store externo?

Al terminar vas a poder explicar

  • Explicar qué es el tearing y por qué apareció recién con la concurrencia
  • Describir el contrato de las tres funciones que recibe el hook
  • Detectar la trampa de pasar una función subscribe inline

Antes de React 18, leer un store externo se hacía así y funcionaba:

1function useAnchoDeVentana() {2  const [ancho, setAncho] = useState(window.innerWidth);3 4  useEffect(() => {5    const alCambiar = () => setAncho(window.innerWidth);6    window.addEventListener("resize", alCambiar);7    return () => window.removeEventListener("resize", alCambiar);8  }, []);9 10  return ancho;11}

Con renderizado concurrente, eso tiene un agujero. Y el agujero tiene nombre: tearing.

Qué es el tearing

Ya sabes que React puede renderizar en pasadas y abandonar trabajo a la mitad. Ahora imagina que estás en medio de una pasada, con la mitad del árbol renderizado, y el store externo cambia justo ahí.

lo que pasa sin protección
1Componente A renderiza → lee del store → "modo claro"2                         ⟵ React cede el hilo un instante3                         ⟵ el store cambia a "modo oscuro"4Componente B renderiza → lee del store → "modo oscuro"5 6// Se commitea el árbol: A dice claro, B dice oscuro.7// Misma fuente de verdad, dos valores en pantalla.

El término viene del screen tearing de los monitores: la pantalla mostrando dos cuadros distintos a la vez. Con estado de React esto no puede pasar, porque el estado está congelado en el Fiber durante toda la pasada. Con un store externo, nada lo garantiza.

El contrato de las tres funciones

1const valor = useSyncExternalStore(2  subscribe,          // (alCambiar) => cancelar3  getSnapshot,        // () => valor actual en el cliente4  getServerSnapshot,  // () => valor durante el renderizado en servidor5);
  • subscribe recibe un callback y devuelve la función para cancelar. React lo llama cuando quiere enterarse de los cambios.
  • getSnapshot devuelve el valor actual. Se llama durante el render, no en un efecto. Tiene que devolver un valor cacheado: si devuelves un objeto nuevo cada vez, React entra en un bucle infinito porque ve que cambió en cada comparación.
  • getServerSnapshot es obligatoria si renderizas en el servidor. Ahí no hay window ni suscripciones.

Leyendo un store externo, con el snapshot en el render

AGENDADOpaso 1 / 124
Traza
Cadena de hooks · <—>

Sin hooks en este paso.

Árbol de fibers

  • <HostRoot>

Salida

Nada en el host todavía. React no commiteó ningún cambio.

createRoot().render()

Montaje inicial. No hay árbol previo, así que todo se va a crear desde cero.

VER EL CÓDIGO QUE SE ESTÁ TRAZANDO
1// El estado vive FUERA de React.2function suscribir(alCambiar) {3  window.addEventListener("resize", alCambiar);4  return () => window.removeEventListener("resize", alCambiar);5}6 7const leerAncho = () => window.innerWidth;8const leerEnServidor = () => 1024;     // no hay window en el servidor9 10function Responsivo() {11  const ancho = useSyncExternalStore(suscribir, leerAncho, leerEnServidor);12  return <p>{ancho < 800 ? "compacto" : "amplio"}</p>;13}

Con el foco aquí: ← → un paso · espacio reproduce · Inicio/Fin van a los extremos.

Fíjate en el paso getSnapshot(): aparece dentro de la fase render, antes del commit. Eso es lo que garantiza que todo lo que se renderiza en esa pasada vea el mismo valor. Y React lo vuelve a leer antes de commitear: si cambió a mitad de camino, descarta y renderiza de nuevo.

Las dos trampas

1// ✗ subscribe inline: función nueva en cada render → React2//   cancela y vuelve a suscribir cada vez.3const ancho = useSyncExternalStore(4  (cb) => { window.addEventListener("resize", cb); return () => window.removeEventListener("resize", cb); },5  () => window.innerWidth,6);7 8// ✓ Referencias estables, definidas fuera del componente.9function suscribir(cb) {10  window.addEventListener("resize", cb);11  return () => window.removeEventListener("resize", cb);12}13const leerAncho = () => window.innerWidth;14 15function Componente() {16  const ancho = useSyncExternalStore(suscribir, leerAncho, () => 1024);17}

La segunda trampa es getSnapshot devolviendo un objeto nuevo:

1// ✗ Objeto nuevo en cada llamada → bucle infinito.2const leer = () => ({ ancho: window.innerWidth, alto: window.innerHeight });3 4// ✓ Devolver primitivos, o cachear el objeto y sólo recrearlo al cambiar.5const leerAncho = () => window.innerWidth;6const leerAlto  = () => window.innerHeight;

Antes de seguir · predice

Tu app renderiza en el servidor y useSyncExternalStore tira un error en el build. ¿Qué falta?

Lo que te llevas

El tearing es dos partes de la pantalla mostrando valores distintos del mismo dato, y apareció recién con la concurrencia. useSyncExternalStore lo evita leyendo el snapshot durante el render y verificándolo antes del commit. Cuidado con las referencias inline y con devolver objetos nuevos.

Antes de marcarla, comprueba

  • ¿Puedes explicar explicar qué es el tearing y por qué apareció recién con la concurrencia?
  • ¿Puedes explicar describir el contrato de las tres funciones que recibe el hook?
  • ¿Puedes explicar detectar la trampa de pasar una función subscribe inline?