La arquitectura de React 19 ha elevado la asincronía y la interacción con el planificador concurrente (Concurrent Scheduler) a un nuevo estándar de ingeniería. El patrón tradicional consistente en «obtener datos dentro de useEffect y gestionar banderas locales isLoading/error mediante useState» ha sido sustituido por primitivas declarativas en React 19:
use(Promise): Permite leer el resultado de una promesa directamente en el cuerpo funcional del componente durante la fase de renderizado, integrándose de forma nativa con el árbol <Suspense>.useTransition): Permiten marcar las mutaciones de estado como transiciones en segundo plano no bloqueantes, preservando la capacidad de respuesta de la interfaz y exponiendo la bandera isPending.useActionState y useOptimistic): Estandarizan la gestión del ciclo de vida de acciones asíncronas de servidor y cliente, facilitando retroalimentación inmediata mediante mutaciones optimistas.ErrorBoundary): Interceptan excepciones no controladas y promesas rechazadas (rejected promises), evitando caídas catastróficas de la aplicación hacia pantallas en blanco.Probar componentes asíncronos en React 19 requiere comprender la mecánica de fases del planificador: un componente puede suspender su ejecución (suspend), arrojando la promesa hacia el límite <Suspense> ascendente más cercano, re-renderizarse tras la resolución de la microtarea o procesar transiciones en paralelo sin congelar el hilo principal.
use(Promise) y sus mecanismos complementarios transforman la metodología de pruebas: en lugar de aguardar la activación de un useEffect, el test asume el control directo del ciclo de vida de la promesa.
Al procesar un componente que ejecuta use(promise), React examina el estado interno de la promesa. Si la promesa permanece en estado pending, React interrumpe la ejecución del nodo Fiber y monta la interfaz de respaldo (fallback, como skeletons o spinners) declarada por el componente <Suspense> padre más próximo.
En cuanto la promesa pasa al estado fulfilled, el planificador de React 19 programa un nuevo renderizado del componente con el valor resuelto. Si la promesa resulta rechazada (rejected), React propaga el error hacia el <ErrorBoundary> más cercano.
+-----------------------------------------------------------------------+
| Component Render: const data = use(deferredPromise) |
+-----------------------------------------------------------------------+
│
┌─────────────────────────┴─────────────────────────┐
▼ (Promise: pending) ▼ (Promise: fulfilled)
+----------------------------------+ +------------------------------------+
| Fiber suspende el render. | | Se monta el contenido principal |
| Se muestra el fallback: | | con los datos procesados: |
| <Suspense fallback={<Skeleton/>}>| | screen.getByText(data.title) |
+----------------------------------+ +------------------------------------+
│ ▲
▼ (En test: deferred.resolve(payload)) │
+------------------------------------------------------------+
| Resolución de promesa -> React programa re-renderizado |
+------------------------------------------------------------+Con el propósito de evaluar de forma determinista tanto la fase del skeleton de carga como la presentación definitiva de los datos, el test debe controlar con precisión el instante de resolución. Para ello se emplea una factoría createDeferredPromise<T>():
export interface DeferredPromise<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
readonly reject: (reason: unknown) => void;
}
export function createDeferredPromise<T>(): DeferredPromise<T> {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}use(Promise) y <Suspense>#Cualquier componente que invoque use() debe montarse necesariamente dentro de un límite <Suspense>:
import { Suspense, use } from 'react';
function UserCard({ userPromise }: { readonly userPromise: Promise<{ name: string }> }) {
const user = use(userPromise);
return <div>Usuario: {user.name}</div>;
}
// En el test:
render(
<Suspense fallback={<div role="status">Cargando...</div>}>
<UserCard userPromise={deferred.promise} />
</Suspense>
);useTransition e isPending#El hook useTransition devuelve la tupla [isPending, startTransition]. En las pruebas resulta esencial validar el estado transitorio isPending antes de culminar las microtareas:
const [isPending, startTransition] = useTransition();
// Inicio de actualización no bloqueante
startTransition(async () => {
await updateData();
});useActionState (Acciones de React 19)#El hook useActionState sustituye al anterior useFormState:
const [state, formAction, isPending] = useActionState(
async (prevState: State, formData: FormData) => {
return await saveArticleAction(formData);
},
initialState
);use(Promise) y Deferred Promise#El test corrobora la visualización del skeleton, posteriormente resuelve la promesa y valida la interfaz definitiva.
import { describe, it, expect } from 'vitest';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import React, { Suspense, use } from 'react';
interface MetricData {
readonly activeUsers: number;
readonly latencyMs: number;
}
function MetricContent({ dataPromise }: { readonly dataPromise: Promise<MetricData> }) {
const data = use(dataPromise);
return (
<article aria-label="Métricas de rendimiento">
<p>Sesiones activas: {data.activeUsers}</p>
<p>Latencia media: {data.latencyMs} ms</p>
</article>
);
}
function MetricWidget({ dataPromise }: { readonly dataPromise: Promise<MetricData> }) {
return (
<Suspense fallback={<div role="status">Inicializando métricas...</div>}>
<MetricContent dataPromise={dataPromise} />
</Suspense>
);
}
// Factoría de promesa gestionada
function createDeferredPromise<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('MetricWidget with React 19 use(Promise)', () => {
it('muestra el skeleton de respaldo antes de resolver la promesa y los datos después', async () => {
const deferred = createDeferredPromise<MetricData>();
render(<MetricWidget dataPromise={deferred.promise} />);
// 1. Verificar la presencia del fallback de Suspense
const fallback = screen.getByRole('status');
expect(fallback).toHaveTextContent('Inicializando métricas...');
// La sección principal aún no debe figurar en el DOM
expect(
screen.queryByRole('region', { name: 'Métricas de rendimiento' })
).not.toBeInTheDocument();
// 2. Resolver la promesa con los datos
deferred.resolve({ activeUsers: 1420, latencyMs: 14 });
// 3. Esperar la eliminación del indicador de carga
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
// 4. Validar el renderizado final de las métricas
expect(
screen.getByRole('region', { name: 'Métricas de rendimiento' })
).toBeInTheDocument();
expect(screen.getByText('Sesiones activas: 1420')).toBeInTheDocument();
expect(screen.getByText('Latencia media: 14 ms')).toBeInTheDocument();
});
});useTransition y Estado isPending#Al conmutar pestañas o aplicar filtros sobre tablas, useTransition permite mostrar el progreso sin bloquear la capacidad interactiva del usuario.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState, useTransition } from 'react';
interface TabProps {
readonly onTabChange: (tab: string) => Promise<void>;
}
function TabNavigator({ onTabChange }: TabProps) {
const [activeTab, setActiveTab] = useState('overview');
const [isPending, startTransition] = useTransition();
const handleSelect = (tab: string) => {
startTransition(async () => {
await onTabChange(tab);
setActiveTab(tab);
});
};
return (
<nav aria-label="Secciones analíticas">
<button
type="button"
aria-selected={activeTab === 'overview'}
onClick={() => handleSelect('overview')}
>
Resumen
</button>
<button
type="button"
aria-selected={activeTab === 'realtime'}
onClick={() => handleSelect('realtime')}
>
Tiempo Real
</button>
{isPending && <span role="progressbar">Cambiando sección...</span>}
<div role="tabpanel">Sección actual: {activeTab}</div>
</nav>
);
}
describe('TabNavigator with useTransition', () => {
it('refleja el estado isPending durante una transición asíncrona', async () => {
const user = userEvent.setup();
let resolveTransition!: () => void;
const transitionPromise = new Promise<void>((res) => {
resolveTransition = res;
});
const mockTabChange = vi.fn().mockImplementation(() => transitionPromise);
render(<TabNavigator onTabChange={mockTabChange} />);
const realtimeBtn = screen.getByRole('button', { name: 'Tiempo Real' });
expect(screen.getByRole('tabpanel')).toHaveTextContent('Sección actual: overview');
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
// Clic sobre la nueva pestaña
await user.click(realtimeBtn);
// Durante la espera de la promesa, el indicador de progreso debe montarse
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Resolver la transición
resolveTransition();
// La barra de progreso desaparece y la pestaña cambia
await screen.findByText('Sección actual: realtime');
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});useActionState e Interfaz Optimista (useOptimistic)#React 19 consolida el tratamiento de acciones en formularios. Con useOptimistic, la interfaz se actualiza al instante sin aguardar la confirmación del servidor.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useActionState, useOptimistic } from 'react';
interface Task {
readonly id: string;
readonly title: string;
readonly pending?: boolean;
}
interface TaskTrackerProps {
readonly initialTasks: readonly Task[];
readonly onAddTask: (title: string) => Promise<Task>;
}
function TaskTracker({ initialTasks, onAddTask }: TaskTrackerProps) {
const [tasks, formAction, isPending] = useActionState(
async (prevTasks: readonly Task[], formData: FormData) => {
const title = formData.get('title') as string;
if (!title) return prevTasks;
// La tarea optimista ya es visible
const savedTask = await onAddTask(title);
return [...prevTasks, savedTask];
},
initialTasks
);
const [optimisticTasks, setOptimisticTasks] = useOptimistic(
tasks,
(current: readonly Task[], newTitle: string) => [
...current,
{ id: 'temp-id', title: newTitle, pending: true },
]
);
const handleSubmit = (formData: FormData) => {
const title = formData.get('title') as string;
setOptimisticTasks(title);
return formAction(formData);
};
return (
<section>
<h2>Lista de tareas</h2>
<form action={handleSubmit} aria-label="Añadir tarea">
<input name="title" aria-label="Título de nueva tarea" />
<button type="submit" disabled={isPending}>
Añadir
</button>
</form>
<ul aria-label="Tareas">
{optimisticTasks.map((t) => (
<li key={t.id} aria-busy={Boolean(t.pending)}>
{t.title} {t.pending && '(Guardando...)'}
</li>
))}
</ul>
</section>
);
}
describe('TaskTracker with useActionState & useOptimistic', () => {
it('renderiza el elemento optimista inmediatamente y consolida con la respuesta del servidor', async () => {
const user = userEvent.setup();
let resolveServerAction!: (task: Task) => void;
const serverPromise = new Promise<Task>((res) => {
resolveServerAction = res;
});
const mockAddTask = vi.fn().mockImplementation(() => serverPromise);
render(
<TaskTracker
initialTasks={[{ id: '1', title: 'Aprender React 19' }]}
onAddTask={mockAddTask}
/>
);
const input = screen.getByRole('textbox', { name: 'Título de nueva tarea' });
const submitBtn = screen.getByRole('button', { name: 'Añadir' });
await user.type(input, 'Escribir pruebas unitarias');
await user.click(submitBtn);
// 1. La entrada optimista figura síncronamente antes de la respuesta del servidor
expect(
screen.getByText(/Escribir pruebas unitarias \(Guardando\.\.\.\)/)
).toBeInTheDocument();
// 2. Resolución de la acción en el servidor
resolveServerAction({ id: 'srv-99', title: 'Escribir pruebas unitarias' });
// 3. El indicador temporal desaparece y la tarea definitiva queda registrada
const finalItem = await screen.findByText('Escribir pruebas unitarias');
expect(finalItem).toBeInTheDocument();
expect(screen.queryByText(/Guardando\.\.\./)).not.toBeInTheDocument();
});
});ErrorBoundary#Cuando una promesa asíncrona evaluada con use(Promise) se rechaza o un componente arroja una excepción, ErrorBoundary debe presentar la interfaz de contingencia sin desestabilizar el test runner ni saturar la consola.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import React, { Component, Suspense, use, type ReactNode, type ErrorInfo } from 'react';
interface ErrorBoundaryProps {
readonly children: ReactNode;
readonly fallback: (error: Error) => ReactNode;
}
interface ErrorBoundaryState {
readonly error: Error | null;
}
class SafeErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
override state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo) {
// En producción: envío de telemetría hacia Sentry
}
override render() {
if (this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
function BrokenComponent({ dataPromise }: { readonly dataPromise: Promise<string> }) {
const result = use(dataPromise);
return <div>Datos: {result}</div>;
}
describe('SafeErrorBoundary with Rejected use(Promise)', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Silenciamos la traza de excepción no controlada en el stderr del test runner
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('captura la promesa rechazada de use() y muestra el fallback de error', async () => {
const rejectedPromise = Promise.reject(new Error('Gateway Timeout 504'));
render(
<SafeErrorBoundary
fallback={(err) => (
<div role="alert">
<h2>Error de carga</h2>
<p>{err.message}</p>
</div>
)}
>
<Suspense fallback={<div>Cargando...</div>}>
<BrokenComponent dataPromise={rejectedPromise} />
</Suspense>
</SafeErrorBoundary>
);
// Esperar de manera asíncrona a que el Error Boundary procese el fallo
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Error de carga');
expect(alert).toHaveTextContent('Gateway Timeout 504');
});
});Declarar promesas en pleno renderizado (const p = fetch(...)) provoca que en cada reintento de Suspense se genere una instancia inédita, incurriendo en un bucle infinito de Suspense (Infinite Suspense Loop).
// ❌ ANTIPATRÓN: La promesa se crea de nuevo en cada tick de render
function BadProfile() {
const data = use(fetch('/api/user')); // ¡Bucle infinito!
return <div>{data.name}</div>;
}
// ✅ SOLUCIÓN: La promesa se transmite por props o proviene de una caché estable
function GoodProfile({ userPromise }: { readonly userPromise: Promise<User> }) {
const data = use(userPromise);
return <div>{data.name}</div>;
}Consultar de forma síncrona el contenido con screen.getByRole(...) prescindiendo de findBy* o waitForElementToBeRemoved derivará en fallos porque React aún se encuentra en estado de suspensión.
// ❌ ANTIPATRÓN: Consulta síncrona hacia contenido que aún no se ha montado
deferred.resolve({ id: 1 });
expect(screen.getByText('Contenido')).toBeInTheDocument(); // ¡Fallo!
// ✅ SOLUCIÓN: Espera asíncrona tras el desmontaje del skeleton
deferred.resolve({ id: 1 });
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
expect(await screen.findByText('Contenido')).toBeInTheDocument();React registra por omisión las excepciones capturadas en console.error aun cuando sean atrapadas por componentDidCatch. Esto puede suscitar falsas alarmas en pipelines de CI.
// ❌ ANTIPATRÓN: Traza de excepción no controlada en la salida del test
render(<SafeErrorBoundary><FailingComponent /></SafeErrorBoundary>);
// ✅ SOLUCIÓN: Silenciar console.error con vi.spyOn durante la ejecución del test
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});Una transición ejecutada dentro de startTransition(async () => ...) planifica tareas concurrentes de baja prioridad. El act síncrono no espera la resolución de microtareas asíncronas anidadas.
// ❌ ANTIPATRÓN: act síncrono no aguarda microtareas de transición
act(() => {
startTransition(() => { fetchData(); });
});
// ✅ SOLUCIÓN: Utilizar await act() o resolver con findBy
await act(async () => {
await triggerAsyncTransition();
});Inicializar un Deferred Promise con createDeferredPromise() exponiendo callbacks resolve/reject.
Envolver el componente en límites defensivos para interceptar estados pendientes o rechazados.
Verificar la presencia del indicador de carga mediante getByRole('status' | 'progressbar').
Invocar manualmente deferred.resolve(data) o deferred.reject(error) para modificar el estado.
Aguardar el desmontaje del skeleton (waitForElementToBeRemoved) y verificar la UI definitiva.