En el vertiginoso mundo del desarrollo web, la experiencia del usuario (UX) es un pilar fundamental. Una aplicación que se siente lenta, que bloquea su interfaz o que no responde fluidamente a las interacciones del usuario, está condenada a ser abandonada. Durante mucho tiempo, los desarrolladores de React han luchado por equilibrar la complejidad de la lógica de negocio con la necesidad imperiosa de una UI fluida y reactiva. Las actualizaciones pesadas o las operaciones costosas podían congelar momentáneamente la aplicación, creando una experiencia frustrante para el usuario.
Con el lanzamiento de React 18, llegó una serie de nuevas características y mejoras, pero quizás ninguna tan impactante como la introducción de las capacidades de renderizado concurrente. Esta nueva filosofía permite a React trabajar en múltiples estados a la vez, dando prioridad a las actualizaciones más urgentes (como la entrada del usuario) sobre las menos urgentes (como la actualización de una tabla de datos compleja). Esta distinción es crucial para mantener la UI interactiva y responsiva. En este artículo, exploraremos dos de los hooks más significativos que nos permiten aprovechar esta concurrencia: useTransition y useDeferredValue. A través de ejemplos prácticos con código, desglosaremos cómo estas herramientas pueden transformar la percepción de rendimiento de tus aplicaciones React.
La promesa de React 18: concurrencia y UI responsiva
Antes de sumergirnos en los hooks específicos, es vital comprender el concepto de concurrencia en React. Históricamente, React procesaba las actualizaciones de forma síncrona y unidireccional. Cuando una actualización de estado se disparaba, React detenía lo que estaba haciendo, renderizaba de nuevo y luego actualizaba el DOM. Si esta actualización implicaba mucho trabajo computacional (por ejemplo, filtrar una lista muy grande o renderizar un componente complejo), la interfaz de usuario podía "congelarse" momentáneamente, dejando al usuario sin una respuesta visual inmediata a sus acciones.
La concurrencia en React 18 cambia este paradigma. Ahora, React puede interrumpir, pausar y reanudar el renderizado cuando sea necesario. Puede incluso descartar el trabajo de renderizado si hay una actualización de mayor prioridad que requiere atención inmediata. Imagina que estás escribiendo en un campo de búsqueda y, al mismo tiempo, el sistema está filtrando una lista masiva de resultados. Antes, cada pulsación de tecla podría hacer que la interfaz se sintiera lenta porque el renderizado del filtro se realizaba de inmediato. Con la concurrencia, React puede priorizar la actualización del campo de entrada y "dejar para después" la actualización del filtro si el sistema está ocupado, o incluso si detecta que el usuario sigue escribiendo.
Esta capacidad de priorizar y programar el trabajo permite a los desarrolladores diseñar experiencias de usuario mucho más fluidas y reactivas, incluso en aplicaciones con componentes complejos o datos voluminosos. Es un cambio fundamental en cómo React gestiona el renderizado y abre la puerta a patrones de UI más sofisticados y agradables. Es mi opinión que esta es una de las mayores innovaciones de React en años, y si bien al principio puede parecer una abstracción compleja, sus beneficios en la experiencia de usuario son innegables y justifican el aprendizaje.
Entendiendo las actualizaciones en React
Para apreciar plenamente useTransition y useDeferredValue, es esencial comprender cómo React clasifica y gestiona las diferentes actualizaciones de estado.
Actualizaciones urgentes vs. no urgentes
React 18 introduce la distinción entre actualizaciones "urgentes" y "no urgentes" (o de "transición").
- Actualizaciones urgentes: Son aquellas que deben reflejarse en la UI de inmediato para que la aplicación se sienta interactiva y responsiva. El ejemplo clásico es la entrada del usuario en un campo de texto. Si un usuario escribe una letra y esta no aparece instantáneamente, la aplicación se sentirá lenta o rota. Otros ejemplos incluyen hacer clic en un botón, enfocar un elemento, etc. React siempre procesará estas actualizaciones con la máxima prioridad, interrumpiendo cualquier trabajo menos urgente si es necesario.
- Actualizaciones no urgentes (transiciones): Son aquellas que pueden tardar un poco en reflejarse sin que la experiencia del usuario se vea afectada negativamente. Por ejemplo, al filtrar una lista de productos, el usuario no espera que los resultados se actualicen en cada milisegundo mientras escribe. Un ligero retraso, mientras la interfaz de entrada permanece fluida, es perfectamente aceptable. Otro caso podría ser navegar a una nueva página en una SPA, donde la nueva vista puede cargarse en segundo plano mientras se mantiene la interactividad de la página actual.
La clave está en cómo React maneja estas actualizaciones. Las actualizaciones urgentes bloquean el renderizado hasta que se completan. Las actualizaciones no urgentes, sin embargo, pueden ser interrumpidas por actualizaciones urgentes. Si una actualización no urgente está en progreso y el usuario realiza una acción urgente, React detendrá la actualización no urgente, procesará la urgente y luego reanudará (o reiniciará) la no urgente. Esta es la esencia de la concurrencia y lo que useTransition y useDeferredValue nos permiten controlar explícitamente. Puedes consultar más detalles sobre el renderizado concurrente en la guía de actualización a React 18.
`useTransition`: mantén tu UI interactiva
El hook useTransition nos permite marcar ciertas actualizaciones de estado como "transiciones", indicándole a React que pueden ser interrumpidas. Esto es ideal para escenarios donde una actualización de estado puede ser pesada, pero no es crítica que se refleje instantáneamente.
useTransition devuelve una tupla con dos elementos:
isPending: Un valor booleano que indica si una transición está actualmente en curso. Esto es útil para mostrar un spinner o un indicador de carga al usuario.startTransition: Una función que envuelve la actualización de estado que queremos marcar como una transición. Cualquier actualización de estado dentro destartTransitionse tratará como no urgente.
import { useState, useTransition } from 'react';
function ProductSearch() {
const [inputValue, setInputValue] = useState('');
const [filteredProducts, setFilteredProducts] = useState([]);
const [isPending, startTransition] = useTransition();
const allProducts = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Producto ${i}`,
description: `Descripción detallada del producto ${i}`,
}));
const handleChange = (e) => {
const value = e.target.value;
setInputValue(value); // Actualización urgente para el input
startTransition(() => {
// Esta actualización de estado es una transición
// Puede ser interrumpida por otras actualizaciones urgentes (como escribir más rápido)
const start = performance.now(); // Para simular un cálculo pesado
const filtered = allProducts.filter(product =>
product.name.toLowerCase().includes(value.toLowerCase())
);
// Simula un trabajo pesado prolongado
while (performance.now() - start < 100) {} // Bloquea por 100ms
setFilteredProducts(filtered);
});
};
return (
<div>
<input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="Buscar productos..."
style={{ padding: '10px', width: '300px', fontSize: '1em' }}
/>
{isPending && <p style={{ color: 'gray' }}>Cargando resultados...</p>}
<ul style={{ listStyle: 'none', padding: 0 }}>
{filteredProducts.map(product => (
<li key={product.id} style={{ borderBottom: '1px solid #eee', padding: '8px 0' }}>
{product.name}
</li>
))}
</ul>
</div>
);
}
export default ProductSearch;
Ejemplo práctico con useTransition
En el código anterior, hemos creado un componente ProductSearch que simula una búsqueda en una lista de 10,000 productos.
- Estado del input (
inputValue): Cada vez que el usuario escribe,setInputValue(value)actualiza el estado del input. Esta es una actualización urgente; queremos que el texto aparezca inmediatamente. - Estado de los productos filtrados (
filteredProducts): La lógica de filtrado es potencialmente costosa, especialmente en una lista grande. Envolvemos la actualización desetFilteredProductsdentro destartTransition. Esto le dice a React: "Oye, esta actualización de la lista filtrada no es tan urgente. Si el usuario sigue escribiendo, puedes pausar o descartar este renderizado y priorizar la actualización del campo de entrada." - Indicador de carga (
isPending): El booleanoisPendinges true mientras la transición está en curso. Lo usamos para mostrar el mensaje "Cargando resultados...", lo cual mejora la retroalimentación al usuario. SinuseTransition, esta interfaz se sentiría muy lenta. El input se congelaría mientras el filtrado se realiza. ConuseTransition, el input permanece totalmente responsivo. El usuario puede escribir rápidamente, y la lista se actualizará una vez que React tenga tiempo para procesar la transición, o después de que el usuario haya terminado de escribir y el navegador ya no tenga más acciones urgentes.
`useDeferredValue`: postergando la re-renderización
Mientras que useTransition te permite marcar tus propias actualizaciones de estado como no urgentes, useDeferredValue es útil cuando tienes un valor que se actualiza con frecuencia y no necesitas que la interfaz que depende de ese valor se re-renderice instantáneamente. Es decir, difiere el valor en sí, no la actualización del estado. Es como tener una versión "fresca" del valor para las actualizaciones urgentes y una versión "aplazada" para las actualizaciones menos urgentes.
useDeferredValue recibe un valor y devuelve una versión diferida de ese valor. La versión diferida solo se actualizará después de que React haya tenido tiempo de procesar otras actualizaciones más urgentes.
import { useState, useDeferredValue } from 'react';
// Componente simulado que realiza un trabajo pesado para renderizar
function ExpensiveComponent({ value }) {
const start = performance.now();
// Simula un cálculo o renderizado intensivo
while (performance.now() - start < 200) {} // Bloquea por 200ms
return (
<div style={{ padding: '15px', border: '1px solid #ccc', marginTop: '10px' }}>
<h3>Componente Pesado</h3>
<p>Valor mostrado: <strong>{value}</strong></p>
<p style={{ fontSize: '0.8em', color: '#666' }}>Renderizado pesado completado.</p>
</div>
);
}
function ParentComponentWithDeferredValue() {
const [inputValue, setInputValue] = useState('');
const deferredInputValue = useDeferredValue(inputValue);
const handleChange = (e) => {
setInputValue(e.target.value); // Actualización urgente
};
return (
<div>
<input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="Escribe algo..."
style={{ padding: '10px', width: '300px', fontSize: '1em' }}
/>
<p>Valor del input (inmediato): {inputValue}</p>
{/* El componente pesado recibe la versión diferida del valor */}
<ExpensiveComponent value={deferredInputValue} />
</div>
);
}
export default ParentComponentWithDeferredValue;
Ejemplo práctico con useDeferredValue
En este ejemplo:
inputValue: Representa el texto actual en el campo de entrada. Se actualiza inmediatamente cada vez que el usuario escribe, asegurando una experiencia de escritura fluida.deferredInputValue: Es la versión diferida deinputValue.useDeferredValuese asegura de quedeferredInputValuesolo se actualice después de que el navegador esté inactivo o cuando React tenga tiempo libre.ExpensiveComponent: Es un componente "lento" que simula un renderizado costoso (por ejemplo, una gráfica compleja, una tabla con muchos datos). Este componente recibedeferredInputValue.
El efecto es que, mientras el usuario escribe en el input, el inputValue se actualiza al instante, manteniendo el input responsivo. Sin embargo, el ExpensiveComponent que usa deferredInputValue no se re-renderiza inmediatamente con cada pulsación de tecla. En su lugar, espera un momento hasta que React determine que hay tiempo para realizar ese trabajo. Si el usuario sigue escribiendo, deferredInputValue puede quedarse "atrás" del inputValue actual por un breve período, lo cual es preferible a bloquear el input. Esto es particularmente útil cuando el componente "pesado" no necesita mostrar datos en tiempo real, sino que puede tolerar un pequeño retraso para mejorar la interactividad general de la aplicación. Para explorar más a fondo la diferencia, la documentación oficial de React sobre useDeferredValue es un excelente recurso.
¿Cuándo usar cada uno? Similitudes y diferencias clave
Aunque useTransition y useDeferredValue ambos contribuyen a la concurrencia y a una mejor experiencia de usuario, sus propósitos son ligeramente diferentes:
useTransition: Se utiliza cuando tú eres el que inicia la actualización del estado y puedes envolver esa actualización dentro destartTransition. Es ideal cuando tienes control directo sobre la acción que desencadena el renderizado pesado (ej., una búsqueda, un cambio de pestaña). Te permite marcar la acción como "no urgente" y, opcionalmente, mostrar un indicador de carga (isPending).useDeferredValue: Se utiliza cuando tienes un valor que se actualiza con frecuencia (a menudo recibido comopropo derivado de unuseStateque no controlas directamente para su deferencia), y quieres diferir el re-renderizado de una parte de la UI que depende de ese valor. El componente padre se renderizará con el valor "fresco" para las partes urgentes, mientras que el componente hijo (que recibe el valor diferido) se re-renderizará con un valor ligeramente "antiguo" hasta que React decida que tiene tiempo. Es útil cuando el estado original es urgente (como elinputValue), pero una parte de la UI que consume ese estado puede ser menos urgente.
En esencia, useTransition es para tus efectos secundarios (actualizaciones de estado) que pueden esperar, mientras que useDeferredValue es para props o valores computados que un componente hijo puede esperar. La elección depende de si estás marcando una acción completa o solo un valor para un renderizado tardío. Personalmente, me encuentro usando useTransition más a menudo cuando la lógica de la "transición" reside en el mismo componente que la inicia, mientras que useDeferredValue brilla cuando un valor de un componente padre necesita ser pasado a un hijo potencialmente lento sin que el padre se bloquee. Un buen artículo complementario puede ser el de "What's New in React 18", que explora estos conceptos en profundidad.
Consideraciones de rendimiento y buenas prácticas
Si bien useTransition y useDeferredValue son herramientas poderosas, no son una bala de plata. Algunas consideraciones importantes:
- No abusar: No todas las actualizaciones necesitan ser transiciones o diferidas. Usarlos para cada actualización puede añadir una complejidad innecesaria. Aplícalos solo donde notes problemas de UX debido a bloqueos de la UI.
- Feedback visual: Siempre que uses
useTransition, aprovechaisPendingpara proporcionar feedback visual al usuario. Un simple spinner o mensaje de "cargando" puede hacer una gran diferencia en la percepción de la velocidad. - Pruebas: Prueba tus aplicaciones exhaustivamente después de implementar estos hooks. Asegúrate de que el comportamiento diferido sea el esperado y no introduzca errores lógicos o problemas de sincronización de datos inesperados. La documentación de cómo probar la interfaz de usuario en React ofrece buenas pautas.
- Evita efectos secundarios fuera de
startTransition: Las funciones pasadas astartTransitiondeben ser puras y no deben tener efectos secundarios observables fuera de la actualización del estado. React.memoyuseCallback: Estos optimizadores siguen siendo relevantes. Deferir renderizados costosos es una cosa, pero si el componente que se renderiza diferidamente sigue recalculando propiedades costosas o se re-renderiza cuando no es necesario, estos hooks no resolverán el problema de rendimiento subyacente. Considera siempre la memorización conReact.memopara componentes pesados yuseCallback/useMemopara funciones y valores pasados como props, incluso cuando uses concurrencia. Una lectura interesante sobre optimizaciones en React es este artículo de "React Optimisation Techniques".
Conclusión
React 18 marca un hito importante en la evolución de la biblioteca, introduciendo la concurrencia como un pilar fundamental para construir experiencias de usuario superiores. useTransition y useDeferredValue son nuestras principales he