JavaScript en el navegador se ejecuta en un bucle de eventos monohilo (Event Loop). En el renderizado síncrono convencional, la evaluación de un subárbol de componentes pesado monopoliza de forma exclusiva este único hilo: si computar un grafo o renderizar una tabla con 10.000 filas requiere 120 ms, el navegador resulta incapaz de atender un solo clic, pulsación de tecla o desplazamiento durante ese intervalo.
Esto origina congelamientos en la interfaz (UI Freeze) y una degradación severa de la métrica de Core Web Vitals INP (Interaction to Next Paint).
Históricamente, los ingenieros recurrían a soluciones de compromiso: técnicas de debouncing (debounce), throttling (throttle) o delegar cálculos a Web Workers. Sin embargo, estas estrategias introducen una latencia artificial impuesta o exigen una costosa serialización de datos a través de postMessage.
El Concurrent Rendering (Renderizado concurrente) en React 19 resuelve este problema desde los fundamentos del planificador del núcleo. Es un mecanismo de multitarea cooperativa e interrumpible (Preemption), que permite a React preparar varias versiones del árbol de componentes en paralelo. El renderizado deja de ser un proceso monolítico y bloqueante: el trabajo se divide en ventanas de tiempo de 5 ms (Time-Slicing), y las actualizaciones de estado se clasifican en dos flujos conceptuales:
input, clics de botones, cambios de foco) que demandan una respuesta visual instantánea.Si un usuario pulsa una tecla mientras se computa una transición en segundo plano, React desaloja inmediatamente el renderizado en curso, procesa la pulsación urgente de manera síncrona en el campo de texto y solo después reanuda el cálculo secundario.
El planificador concurrente de React ejecuta una multitarea cooperativa mediante presupuestos de 5 ms. Para secuenciar tareas sin la sobrecarga añadida de los temporizadores estándar, el planificador utiliza la API MessageChannel.
Renderizado síncrono bloqueante (React 15-17):
[ El usuario escribe "A" ]
│
▼
[ Render bloqueante 100ms: ============================== ] ──► Fotograma congelado (Frame Drops)
Renderizado concurrente con Time-Slicing y apropiación (React 19):
[ El usuario escribe "A" ]
│
▼
[Cuanto 5ms] ──► [Cuanto 5ms] ──► [ ¡Pulsada tecla "B"! ] (Urgent Task)
│
▼
[Interrupción del render secundario]
[Render urgente del input: 2ms] ──► Pintado fluido (120 FPS)
│
▼
[Reanudación del render "AB" en segundo plano]Cuando un componente envuelve una actualización en startTransition, React canaliza el cambio hacia los canales de prioridad TransitionLanes. Durante la construcción del árbol workInProgress, el motor consulta continuamente shouldYield(). Si aparece un evento urgente en la cola del navegador, el árbol workInProgress se suspende o descarta, cediendo el hilo al motor del navegador.
En la percepción humana, la respuesta a una pulsación de tecla o un clic debe ocurrir en menos de 100 ms (idealmente entre 8 y 16 ms, un fotograma de pantalla). Filtrar un catálogo o calcular una gráfica compleja puede requerir 300–500 ms sin que el usuario perciba lentitud, siempre y cuando el campo de entrada responda de forma instantánea y fluida.
El manejador ejecuta startTransition. React asigna el contexto de prioridad baja correspondiente a TransitionLanes.
React conmuta síncronamente isPending = true, lo que permite al componente mostrar un indicador de carga o reducir la opacidad.
Se ejecuta workLoopConcurrent. El motor construye el árbol workInProgress en segmentos de 5 ms mediante MessageChannel, cediendo el control periódicamente.
Si se genera una pulsación de tecla o un clic, el renderizado en segundo plano se interrumpe inmediatamente para dar paso a SyncLane.
Una vez concluido el cálculo, el nuevo árbol se transfiere atómicamente al DOM real e isPending vuelve a ser false.
¿Qué sucede físicamente en el hilo principal del navegador cuando un usuario pulsa una tecla mientras se procesa un startTransition en segundo plano?
El usuario pulsa o escribe. React captura el evento mediante su sistema sintético de delegación.
La entrada directa recibe SyncLane (prioridad 1). Las funciones dentro de startTransition reciben TransitionLane.
Si se está renderizando una transición y entra un SyncLane, WorkLoop descarta el árbol en curso y ejecuta SyncLane.
Una vez concluidas las actualizaciones urgentes, la transición se reinicia con los datos finales y se aplica al DOM.
En React 19, useTransition introduce una mejora sustancial: startTransition admite de forma nativa funciones asíncronas (Async Transitions). El estado isPending se mantiene activo durante todo el ciclo de resolución de las promesas (await), eliminando la necesidad de gestionar variables de estado booleanas manuales.
import React, { useState, useTransition } from 'react';
interface Product {
id: string;
name: string;
category: string;
price: number;
}
interface ProductCatalogProps {
initialProducts: Product[];
}
export function EnterpriseProductCatalog({ initialProducts }: ProductCatalogProps) {
const [searchTerm, setSearchTerm] = useState('');
const [filteredProducts, setFilteredProducts] = useState(initialProducts);
const [isPending, startTransition] = useTransition();
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextQuery = event.target.value;
// 1. ACTUALIZACIÓN URGENTE: Reflejar de inmediato la tecla en el input (SyncLane)
setSearchTerm(nextQuery);
// 2. TRANSICIÓN ASÍNCRONA: Filtrado en segundo plano (TransitionLane)
startTransition(async () => {
// Simulación de cálculo o consulta de búsqueda asíncrona
const results = initialProducts.filter(item =>
item.name.toLowerCase().includes(nextQuery.toLowerCase())
);
setFilteredProducts(results);
});
};
return (
<div className="catalog-container p-6">
<div className="search-header flex items-center gap-4 mb-6">
<input
type="text"
value={searchTerm}
onChange={handleSearchChange}
placeholder="Buscar entre 50.000 productos..."
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-accent-cyan"
/>
{isPending && (
<span className="text-xs text-text-muted animate-pulse font-mono">
Actualizando catálogo...
</span>
)}
</div>
<div className={`products-grid transition-opacity duration-200 ${isPending ? 'opacity-60' : 'opacity-100'}`}>
<p className="text-sm text-text-muted mb-4">Coincidencias encontradas: {filteredProducts.length}</p>
<div className="grid grid-cols-3 gap-4">
{filteredProducts.map(product => (
<div key={product.id} className="p-4 border border-border rounded-md bg-bg-surface">
<h4 className="font-semibold">{product.name}</h4>
<p className="text-accent-cyan">${product.price}</p>
</div>
))}
</div>
</div>
</div>
);
}El hook useDeferredValue permite diferir la actualización de un valor derivado hasta que las pasadas de renderizado urgentes se hayan ejecutado.
Mecánica interna de useDeferredValue:
value, React realiza primero un renderizado en el que useDeferredValue devuelve el valor anterior. La actualización urgente (la posición del cursor) se dibuja sin retardo.TransitionLane donde useDeferredValue devuelve el nuevo valor.initialValue, permitiendo establecer un valor inicial antes del primer montaje del componente.import React, { useState, useDeferredValue, memo } from 'react';
interface AnalyticsDataPoint {
timestamp: number;
cpuLoad: number;
memoryUsage: number;
}
// Componente pesado de visualización memorizado para evitar cálculos redundantes
const HeavyChart = memo(function HeavyChart({ data }: { data: AnalyticsDataPoint[] }) {
const startTime = performance.now();
while (performance.now() - startTime < 40) {
// Carga de CPU simulada de 40 ms por renderizado
}
return (
<div className="chart-canvas border p-4 rounded bg-bg-elevated">
<p className="text-xs text-text-muted">Puntos de datos computados: {data.length}</p>
<div className="h-48 flex items-end gap-1 mt-2">
{data.slice(-50).map((point, idx) => (
<div
key={idx}
style={{ height: `${point.cpuLoad}%` }}
className="w-2 bg-accent-cyan rounded-t-sm"
/>
))}
</div>
</div>
);
});
export function AnalyticsDashboard({ fullHistory }: { fullHistory: AnalyticsDataPoint[] }) {
const [rangeLimit, setRangeLimit] = useState(500);
// Valor diferido: el deslizador responde a 120 FPS, la gráfica computa en segundo plano
const deferredLimit = useDeferredValue(rangeLimit, 500);
const isStale = rangeLimit !== deferredLimit;
const currentDataSlice = fullHistory.slice(0, deferredLimit);
return (
<div className="analytics-box space-y-4">
<div className="control-panel flex items-center justify-between">
<label className="text-sm font-medium">
Muestra analizada: {rangeLimit} registros
</label>
<input
type="range"
min="100"
max="10000"
step="100"
value={rangeLimit}
onChange={e => setRangeLimit(Number(e.target.value))}
className="w-64 cursor-pointer"
/>
</div>
<div className={`chart-wrapper transition-opacity duration-150 ${isStale ? 'opacity-50' : 'opacity-100'}`}>
<HeavyChart data={currentDataSlice} />
</div>
</div>
);
}¿Por qué está contraindicado envolver la actualización de estado de un campo controlado (setInputValue) directamente dentro de startTransition?
Para verificar que las transiciones secundarias no perjudican el Interaction to Next Paint (INP), implemente este hook personalizado que audita los tiempos de ejecución:
import { useTransition, useCallback, useRef } from 'react';
interface TransitionTelemetry {
durationMs: number;
taskName: string;
isPreempted: boolean;
}
export function useMonitoredTransition(taskName: string) {
const [isPending, rawStartTransition] = useTransition();
const startTimeRef = useRef<number>(0);
const startTransitionWithTelemetry = useCallback(
(action: () => Promise<void> | void) => {
startTimeRef.current = performance.now();
rawStartTransition(async () => {
try {
await action();
} finally {
const totalDuration = performance.now() - startTimeRef.current;
if (totalDuration > 50) {
console.warn(
`[INP Warning] La transición en segundo plano "${taskName}" requirió ${totalDuration.toFixed(1)} ms. Se aconseja optimizar el grafo Fiber.`
);
}
}
});
},
[rawStartTransition, taskName]
);
return [isPending, startTransitionWithTelemetry] as const;
}Colocar setInputValue(e.target.value) dentro de startTransition rompe la sincronización entre el elemento nativo del DOM y el estado de React. El cursor salta al final del campo de texto, los caracteres tardan en aparecer y la escritura se siente poco fluida. ¡La entrada de texto debe ejecutarse de forma estrictamente síncrona en SyncLane!
// ❌ INCORRECTO: Diferir la entrada directa desalinea el cursor y genera latencia
function BadSearch() {
const [query, setQuery] = useState('');
const [, startTransition] = useTransition();
return (
<input
value={query}
onChange={e => {
startTransition(() => {
setQuery(e.target.value); // ¡Latencia visual en el campo!
});
}}
/>
);
}
// ✅ CORRECTO: Entrada síncrona combinada con valor diferido secundario
function GoodSearch() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<HeavyList query={deferredQuery} />
</div>
);
}useDeferredValue solo evita bloquear el hilo si el componente hijo costoso está memorizado (React.memo o compilado por React Compiler). Si el hijo no está memorizado, se renderizará dos veces por cada tecla pulsada: una con el valor previo y otra con el nuevo valor diferido, duplicando el consumo de CPU.
// ❌ INCORRECTO: HeavyList no está memorizado y se renderiza dos veces por tecla
export function SearchPage({ items }: { items: string[] }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<HeavyList filter={deferredFilter} items={items} />
</div>
);
}Combinar setTimeout o lodash.debounce con useDeferredValue genera una latencia acumulada. El usuario soporta primero un temporizador artificial de 300 ms antes de que React comience a cuantificar el tiempo. Utilice directamente startTransition o useDeferredValue: el planificador ejecutará el renderizado de inmediato en cuanto la CPU esté libre.
Dado que las transiciones secundarias son interrumpibles y pueden reiniciarse en caso de desalojo, modificar variables externas o mutar estructuras en el callback de la transición introduce condiciones de carrera (Race Conditions). El código dentro de una transición debe mantenerse referencialmente puro.