useSyncExternalStore suscribe un componente a datos que viven fuera de React y lo vuelve a renderizar cada vez que esos datos cambian. Le das dos funciones: subscribe, que le dice a React cómo escuchar los cambios, y getSnapshot, que devuelve el valor actual.
Los dos componentes Display no comparten props ni contexto, y aun así se actualizan juntos, porque ambos se suscriben al mismo store. El botón llama a una función normal, no a un setter de React. Agrega un tercer <Display name="Sidebar" /> y se suma sin ningún otro cambio.
La sintaxis
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
subscribe(callback)empieza a escuchar, llama acallbackcada vez que los datos pueden haber cambiado y devuelve una función para cancelar la suscripción. React la llama después de que el componente se monta y llama a la función devuelta al desmontar.getSnapshot()devuelve el valor actual. React la llama durante cada renderizado y después de cada aviso, y luego compara el resultado con el último usandoObject.is. Mismo valor, ningún renderizado.getServerSnapshot()(opcional) devuelve el valor que se usa en el servidor y durante la hidratación.
Define subscribe fuera del componente, o mantenlo estable con useCallback. Si pasas una función subscribe nueva en cada renderizado, React cancela la suscripción y vuelve a suscribirse cada vez.
APIs del navegador como stores
Cualquier cosa que tenga un valor actual y dispare un evento cuando el valor cambia encaja en esta forma. navigator.onLine con los eventos online y offline es el caso clásico.
Desactiva tu red (o cambia a Offline en las herramientas de desarrollo de tu navegador) y el texto cambia sin recargar. El tercer argumento dice "supón que hay conexión" al renderizar en el servidor, donde no existe navigator. Envolver la llamada al hook en useOnlineStatus lo convierte en un hook personalizado que cualquier componente puede usar.
El ancho de la ventana funciona igual:
Agrega algunas copias y cambia el tamaño de la ventana: cada copia muestra el mismo número en el mismo momento. Cada copia tiene su propio listener, y cada una lee el ancho durante el renderizado, así que ninguna va nunca un frame atrasada.
getSnapshot debe devolver un valor en caché
React llama a getSnapshot a menudo y compara los resultados por referencia. Una función que construye un objeto o array nuevo en cada llamada siempre parece un cambio:
// Broken: a new object on every call
function getSnapshot() {
return { count: store.count, user: store.user };
}
// Also broken: filter returns a new array every time
function getSnapshot() {
return store.todos.filter((t) => !t.done);
}
React renderiza, llama a getSnapshot, recibe un valor "distinto", vuelve a renderizar, y así sucesivamente, hasta que se detiene con "Maximum update depth exceeded". En desarrollo React además registra antes "The result of getSnapshot should be cached to avoid an infinite loop". Un build de producción, como la vista previa de aquí, omite esa advertencia y reporta solo el error final como un código corto (Minified React error #185).
La solución es mantener los datos inmutables en el store: reemplaza el objeto cuando cambie y devuelve la referencia guardada tal cual.
getSnapshot devuelve el mismo objeto state hasta que add lo reemplaza, así que el componente renderiza una vez por cambio. El filtrado ocurre en el componente, después de leer el snapshot, y eso es seguro. Para filtrar dentro del store, calcula el array filtrado cuando cambian los datos y guárdalo, para que getSnapshot pueda devolver la copia guardada.
getServerSnapshot e hidratación
En el servidor no hay ventana, ni navigator, ni suscripción. El tercer argumento le dice a React qué renderizar ahí:
const width = useSyncExternalStore(
subscribe,
() => window.innerWidth, // in the browser
() => 1024 // on the server, and during hydration
);
React también usa getServerSnapshot para el primer renderizado en el navegador cuando hidrata el HTML del servidor, para que ambos coincidan. Justo después de la hidratación lee getSnapshot y, si el valor real es distinto, vuelve a renderizar con él. Sin el tercer argumento, el renderizado en el servidor lanza "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering." Si hay un límite <Suspense> por encima del componente, el servidor envía el fallback de ese límite y el navegador renderiza su contenido; sin ningún límite, el renderizado en el servidor falla.
useSyncExternalStore vs useEffect y useState
Puedes suscribirte con un efecto:
function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
const update = () => setOnline(navigator.onLine);
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return online;
}
Funciona, con dos debilidades. El primer renderizado siempre muestra la suposición inicial, y el valor real llega un renderizado después, cuando se ejecuta el efecto. Y con el renderizado concurrente (durante una transición, por ejemplo), React puede pausar un renderizado a mitad de camino; si el store cambia durante la pausa, los componentes renderizados antes y después pueden mostrar valores distintos. Esa inconsistencia se llama tearing. useSyncExternalStore lee el valor durante el renderizado y hace que React rehaga el renderizado de forma síncrona si el store cambió, así que todos los componentes ven el mismo valor.
Úsalo cuando los datos vivan fuera de React: tu propio módulo de store, una API del navegador, una librería de terceros. La mayoría de las librerías de estado (Redux, Zustand y otras) lo llaman por ti dentro de sus hooks. Para los datos que pertenecen a tus componentes, useState, useReducer y el contexto siguen siendo las herramientas adecuadas.
Preguntas frecuentes
¿Para qué se usa useSyncExternalStore?
Para leer datos que no pertenecen a React y que pueden cambiar por sí solos: un store escrito fuera de React, una librería de estado de terceros, o un valor del navegador como navigator.onLine o el ancho de la ventana. El componente se vuelve a renderizar cada vez que el store le avisa a React que cambió.
¿Qué hacen subscribe y getSnapshot?
subscribe(callback) empieza a escuchar el store, llama a callback en cada cambio y devuelve una función que deja de escuchar. getSnapshot() devuelve el valor actual. React llama a getSnapshot durante el renderizado y después de cada aviso, y vuelve a renderizar solo si el valor cambió según Object.is.
¿Por qué getSnapshot debe devolver un valor en caché?
React compara el resultado de cada llamada a getSnapshot con el anterior. Si devuelve un objeto o array nuevo cada vez, React siempre ve un cambio, vuelve a renderizar, vuelve a llamar a getSnapshot y entra en un bucle hasta lanzar "Maximum update depth exceeded". Devuelve la misma referencia hasta que los datos cambien de verdad.
¿Qué es getServerSnapshot?
El tercer argumento opcional. Devuelve el valor que se usa durante el renderizado en el servidor y durante la hidratación en el navegador, para que ambos produzcan el mismo HTML. Sin él, el componente lanza "Missing getServerSnapshot" en el servidor, y el contenido bajo el límite <Suspense> más cercano se renderiza en el navegador.
¿Debo usar useSyncExternalStore o useEffect con useState?
Para suscribirte a datos externos, prefiere useSyncExternalStore. Lee el valor durante el renderizado, así que el primer renderizado ya es correcto y todos los componentes ven el mismo valor incluso durante el renderizado concurrente. useEffect con useState renderiza una vez con un valor viejo y puede mostrar por un momento valores distintos en distintos componentes.