§ NOTA TÉCNICA · REACT 19
React 19 en tools cliente: batching, useTransition, lo que cambió
tools.ago.cl arrancó en React 19 desde el día uno. Las tools tienen mucho setState en sliders, drag and drop y previews en vivo. Esta nota documenta qué primitivas de React 19 efectivamente movieron la aguja (useTransition, useDeferredValue, batching automático), cómo las usamos en aGo Squeezer, aGo Palette y aGo Canvas, y qué limitaciones encontramos con libs del ecosistema todavía catching up.
Publicado el 2026-05-24. aGo lab.
El contexto: tools con mucho cambio de estado
Una tool de aGo lab típica tiene tres tipos de update de estado que estresan al renderer. Primero, sliders y inputs continuos: cada movimiento del thumb dispara setState a 60 fps o más. Segundo, drag and drop con preview: el usuario arrastra una capa y queremos que el lienzo se actualice fluido. Tercero, exports pesados: generar un PDF, comprimir un batch de imágenes, exportar SVG complejo. Cada uno tiene requisitos de prioridad distintos.
En React 18 manejábamos esto con debouncing manual, throttling, y a veces moviendo cómputo a Web Workers. Funcionaba, pero el código se llenaba de hacks. La pregunta era si React 19 efectivamente simplificaba esto o si las nuevas primitivas eran más marketing que sustancia.
Después de seis meses con React 19 en producción, el veredicto es matizado. Algunas primitivas son muy útiles. Otras son marginales para tools cliente puro. Esta nota separa una cosa de la otra.
Opciones evaluadas para la migración
Opción A · Quedarse en React 18
Estable, ecosistema maduro, sin sorpresas.
Pros: cero riesgo, todas las libs funcionan.
Contras: migración futura inevitable, sin batching mejorado, sin compiler.
Opción B · React 19 desde día uno
Para un proyecto que arranca en 2026, partir en la versión actual.
Pros: sin migración futura, batching automático mejor, primitivas concurrent maduras.
Contras: algunas libs todavía con bugs, DevTools de soporte aún parcial.
Opción C · Híbrido tool por tool
Algunas tools en 18, otras en 19 según necesidad.
Pros: riesgo distribuido.
Contras: mantención de dos sets de patrones, confusión del equipo, build pipeline doble.
La decisión: React 19 unificado
Elegimos la opción B. Tres razones principales.
Una. Las tools tienen mucho setState continuo. El batching automático mejorado y useTransition son exactamente el caso de uso para el que se diseñaron. Pagar el costo de adoptar React 19 prometía rendimiento inmediato.
Dos. Empezar en 19 desde día uno evita una migración futura. Esa migración tiene un costo escondido grande: revisar cada componente, leer changelog de cada lib, debugear regresiones sutiles. Mejor pagar ahora cuando el código es chico que después con 15 tools maduras.
Tres. Las libs que necesitamos (pdf-lib, jspdf, react-pdf, canvas nativo) ya tienen versiones compatibles con React 19. Las que rompían (algunas DnD viejas) las reemplazamos por alternativas modernas (dnd-kit) que ya estaban en nuestro shortlist por otras razones.
useTransition para exports pesados
El primer caso real fue aGo Squeezer. El usuario tiene un batch de 50 imágenes cargadas, mueve un slider de calidad y queremos previsualizar el ahorro estimado en tiempo real. Sin useTransition, mover el slider en React 18 trababa la UI porque cada movimiento disparaba recalculation pesada.
import { useState, useTransition } from 'react';
import { estimarAhorroBatch } from './estimator';
export function CalidadControl({ archivos }: { archivos: File[] }) {
const [calidad, setCalidad] = useState(80);
const [estimacion, setEstimacion] = useState<number | null>(null);
const [isPending, startTransition] = useTransition();
function handleCalidadChange(nueva: number) {
// Update urgente: el slider responde inmediatamente.
setCalidad(nueva);
// Update no urgente: la estimación puede esperar al siguiente frame
// y es interrumpible si el usuario sigue moviendo el slider.
startTransition(async () => {
const ahorro = await estimarAhorroBatch(archivos, nueva);
setEstimacion(ahorro);
});
}
return (
<div className="control">
<label>
Calidad: <strong>{calidad}</strong>
</label>
<input
type="range"
min={1}
max={100}
value={calidad}
onChange={(e) => handleCalidadChange(Number(e.target.value))}
/>
<div className="estimacion">
{isPending ? (
<span aria-live="polite">Calculando ahorro estimado...</span>
) : estimacion !== null ? (
<span>Ahorro estimado: {Math.round(estimacion * 100)}%</span>
) : (
<span>Ajusta el slider para ver el ahorro estimado.</span>
)}
</div>
</div>
);
} El detalle clave es la separación entre setCalidad (urgente, dentro del handler directo) y la estimación (envuelta en startTransition, interrumpible). Cuando el usuario mueve el slider rápido, React abandona transiciones obsoletas y solo termina la última. Sin esto, terminamos calculando estimaciones que ya no importan y bloqueamos el render del slider.
useDeferredValue para preview en vivo
En aGo Palette el usuario ajusta HSL con tres sliders y el preview del color se actualiza en vivo. Pero el preview incluye un canvas que dibuja muestras adyacentes (análogo, complementario, triádico), lo que es costoso. Si actualizamos el canvas en cada cambio del slider, los sliders se trabaron en mobile.
import { useDeferredValue } from 'react';
interface PaletteState {
h: number;
s: number;
l: number;
}
export function PalettePreview({ color }: { color: PaletteState }) {
// Valor diferido: React decide cuándo usarlo según la carga del render anterior.
const colorDiferido = useDeferredValue(color);
// Si los dos valores difieren, sabemos que estamos mostrando una versión "stale"
// mientras el siguiente render se prepara. Útil para mostrar un loading sutil.
const isStale = color !== colorDiferido;
// El canvas pesado usa el valor diferido.
// Los sliders y el chip principal usan el valor actual.
return (
<div className={isStale ? 'palette-stale' : ''}>
<CanvasPesado h={colorDiferido.h} s={colorDiferido.s} l={colorDiferido.l} />
</div>
);
} useDeferredValue es adaptativo. En un equipo potente apenas se nota porque el render del canvas termina rápido. En un equipo lento React decide saltar frames intermedios y solo renderizar el estado final cuando el usuario suelta el slider. Es la diferencia entre debounce manual (siempre fijo) y comportamiento responsivo a la máquina real del usuario.
Batching automático en handlers async
React 18 ya batcheaba updates dentro de event handlers. React 19 lo extiende a promesas resueltas en handlers async sin caer en microtasks separados. Esto importa para flujos donde cargas un archivo y actualizas múltiples estados después de un await.
async function handleFileLoad(file: File) {
const buffer = await file.arrayBuffer();
const metadata = await extraerMetadata(buffer);
// En React 18 estos tres setState producían tres renders separados.
// En React 19 se batchean en un solo render.
setArchivo(file);
setMetadata(metadata);
setEstado('cargado');
}
El beneficio es invisible cuando funciona y costoso cuando no. En React 18 teníamos
que envolver manualmente con flushSync o unstable_batchedUpdates
para evitar renders intermedios visibles como flashes en la UI. En React 19 el batching
es automático y los flashes desaparecen sin código defensivo.
use() para data fetching
El nuevo hook use() permite consumir promesas dentro de un componente.
Para tools cliente puro tiene menos uso que en apps con server components, pero hay
un caso donde rinde: cargar bundles dinámicos al primer render del componente.
import { use, Suspense } from 'react';
// Promesa que carga el codec WebAssembly bajo demanda.
const codecPromise = import('./codecs/avif-wasm');
function AvifEncoder({ imagen }: { imagen: ImageData }) {
// use() suspende el componente hasta que la promesa resuelve.
const codec = use(codecPromise);
const encoded = codec.encode(imagen);
return <ResultadoAvif data={encoded} />;
}
export function ToolPanel({ imagen }: { imagen: ImageData }) {
return (
<Suspense fallback={<p>Cargando codec AVIF...</p>}>
<AvifEncoder imagen={imagen} />
</Suspense>
);
}
En aGo Squeezer usamos este patrón para cargar codecs WASM solo cuando el usuario
efectivamente elige ese formato. Sin use() había que manejar el estado de
carga manualmente con useEffect y una flag. La versión con Suspense es más declarativa
y se integra naturalmente con el árbol de error boundaries.
Limitaciones honestas que encontramos
1. Algunas libs todavía no migran
Material UI v5 tiene incompatibilidades documentadas con React 19. La v6 las arregla, pero hay equipos atrapados en v5 por dependencias custom. Si tu producto depende de MUI v5, planea migrar primero a v6 y luego a React 19. Otras libs en la misma situación: algunas DnD legacy (HTML5 DnD wrappers), libs viejas de virtualización con defaultProps en function components.
2. DevTools experimental
React DevTools soporta React 19 pero algunas funcionalidades (profiler de transitions, scheduling visualization) están en flag experimental. El día a día funciona; el debug avanzado de concurrent rendering todavía es áspero.
3. Mensajes de error son distintos
React 19 reescribió varios errores comunes (hidratación, hooks, key duplicada). Si tu equipo memorizó los mensajes de React 18, prepárate para un período de adaptación. Los nuevos son más claros en general pero distintos.
4. El compiler todavía es opt-in
React Compiler (antes "React Forget") promete eliminar la necesidad de useMemo y useCallback manuales. Al 2026-05-24 sigue en RC. En aGo lab probamos con una tool y funcionó bien, pero no lo habilitamos en producción hasta que sea stable. La promesa es grande; la paciencia es prudente.
5. SSR con Suspense tiene edge cases
Aunque tools.ago.cl es estático puro, Astro server-renderiza el componente React para
el placeholder de la isla. Componentes que usan use() con promesas
infinitas o no estables pueden generar errores de hidratación. Solución: limitar
use() a componentes que solo se hidratan en cliente con
client:only.
6. PropTypes y defaultProps fuera
React 19 eliminó defaultProps en function components (sigue funcionando en
class components). Si tu codebase los usaba, hay que migrar a parámetros default de
ES2015. Lo mismo con PropTypes: ya no funciona en function components, hay que usar
TypeScript o validación runtime explícita.
Cuándo NO migrar todavía
- App estable en producción sin presión de performance. El costo de migrar y revisar regresiones puede no compensar para una app que ya funciona bien. Espera a tener una razón concreta.
- Dependencia pesada de libs no migradas. Si tu stack incluye una lib que todavía no soporta React 19 y no tiene reemplazo viable, mejor esperar.
- Equipo sin experiencia con concurrent rendering. useTransition, useDeferredValue y Suspense requieren mental model nuevo. Si el equipo no tiene tiempo para aprender, migrar produce confusión.
- Tests E2E con asserts frágiles de tiempo. Concurrent rendering puede cambiar el orden de renders intermedios. Tests que dependen del orden exacto pueden flaquear post-migración.
Patrones que recomendamos en tools cliente
-
Todo handler que dispara cómputo no trivial: separar update urgente (slider,
input) de update derivado (estimación, preview). El segundo va en
startTransition. -
Preview en vivo que depende de un valor cambiante: usar
useDeferredValueen lugar de debounce manual. -
Carga dinámica de codecs o libs pesadas: import dinámico envuelto en
use()con Suspense boundary. -
Estados múltiples después de await: confiar en el batching automático. Eliminar
flushSyncmanuales que vienen de React 18. -
Componentes que tocan
windowodocument: marcar la isla conclient:onlypara evitar SSR. - Errores en Suspense: usar error boundaries explícitos por isla, no asumir que el shell global captura todo.
Recursos consultados
- React 19 release notes oficiales.
- RFC del React Compiler y benchmarks comunitarios.
- Issues abiertos de Material UI v5 con React 19 al 2026-05.
- Pruebas internas aGo lab al 2026-05-24 con 15 tools en producción.
¿Quieres probar useTransition en acción?
aGo Squeezer y aGo Palette usan los patrones descritos aquí. Mide tú mismo en mobile.
Abrir aGo Squeezer¿Necesitas migrar a React 19?
aGo lab construye software y migra apps existentes a versiones modernas con foco en performance. Si tu equipo necesita acompañamiento, conversemos.
Conversemos