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 sabés que React puede renderizar en pasadas y abandonar trabajo a la mitad. Ahora imaginá 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 renderizalee del store"modo claro"2React cede el hilo un instante3el store cambia a "modo oscuro"4Componente B renderizalee 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 devolvés un objeto nuevo cada vez, React entra en un bucle infinito porque ve que cambió en cada comparación.
  • getServerSnapshot es obligatoria si renderizás 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 acá: ← → un paso · espacio reproduce · Inicio/Fin van a los extremos.

Fijate 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 · predecí

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

Lo que te llevás

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, comprobá

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