Módulo 17: Activity, View Transitions y más
Objetivos del módulo
- Ocultar parte de la interfaz con
<Activity>sin perder su estado ni dejar efectos en marcha - Precargar una pantalla que el usuario aún no ve
- Saber para qué sirve
cacheSignaly por qué hoy solo tiene sentido en Server Components - Animar cambios de interfaz con
<ViewTransition>y entender qué hace el navegador por debajo - Distinguir los cuatro disparadores:
enter,exit,updateyshare - Personalizar animaciones con clases CSS, con
addTransitionTypey con la Web Animations API - Combinar View Transitions con listas, Suspense y React Router
- Sacar un componente del renderizado en servidor con
browser() - Proteger una aplicación contra XSS con Trusted Types y saber qué cubre React de verdad
- Conocer los demás cambios de 19.3 y dónde se tratan en el curso
💡 Consejo: Todo lo de este módulo está marcado apartado por apartado, porque cada función llegó en una versión distinta.
<Activity>ycacheSignalson de 19.2, y lo demás, de 19.3. Si tu proyecto usa una versión anterior, cada caja te dice qué hacer en su lugar. Las diferencias entre versiones se han medido con 18.3.1, 19.0.9, 19.1.10, 19.2.9 y 19.3.1 en Chromium 156.
1. Prepara el proyecto
Crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:
npm create vite@latest modulo-17 -- --template react-ts
cd modulo-17
npm install
npm run dev
Descarga la API de ejemplo y arráncala en otra terminal con un pequeño retardo, para que los estados de carga se vean:
RETARDO=150 node server.mjs
En PowerShell son dos órdenes: $env:RETARDO=150; node server.mjs. La API queda en http://localhost:3000/api.
Cada ejemplo completo sustituye a src/App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas. La segunda mitad define las animaciones que usarás a partir del apartado 6, y las explicaremos allí:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
padding: 1.25rem;
}
.tarjeta {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
max-width: 680px;
margin: 0 0 1rem;
}
.tarjeta h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: #1a237e;
}
.fila {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
button {
font: inherit;
padding: 0.35rem 0.8rem;
border: 1px solid #3f51b5;
border-radius: 6px;
background: #fff;
color: #283593;
cursor: pointer;
}
button[aria-pressed='true'] {
background: #3f51b5;
color: #fff;
}
input,
textarea {
font: inherit;
padding: 0.35rem 0.5rem;
border: 1px solid #9fa8da;
border-radius: 6px;
box-sizing: border-box;
}
textarea {
width: 100%;
min-height: 5rem;
}
ul.simple {
margin: 0;
padding: 0;
list-style: none;
}
ul.simple li {
padding: 0.3rem 0;
border-bottom: 1px solid #eceff1;
}
.registro {
font: 0.85rem Consolas, monospace;
background: #eceff1;
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin: 0;
min-height: 3rem;
white-space: pre-wrap;
}
.discreto {
color: #607d8b;
}
.miniatura {
display: grid;
place-items: center;
width: 64px;
height: 64px;
border-radius: 10px;
background: #3f51b5;
color: #fff;
font-size: 1.5rem;
font-weight: 600;
}
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.rejilla button {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 0.6rem;
}
.detalle {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.detalle .miniatura {
width: 180px;
height: 180px;
font-size: 4rem;
flex: none;
}
.esqueleto {
height: 5.5rem;
border-radius: 8px;
background: #eceff1;
}
/* Animaciones de View Transitions */
@keyframes desde-derecha {
from { transform: translateX(60px); opacity: 0; }
}
@keyframes desde-izquierda {
from { transform: translateX(-60px); opacity: 0; }
}
@keyframes hacia-derecha {
to { transform: translateX(60px); opacity: 0; }
}
@keyframes hacia-izquierda {
to { transform: translateX(-60px); opacity: 0; }
}
@keyframes aparecer-abajo {
from { transform: translateY(16px); opacity: 0; }
}
@keyframes salir-abajo {
to { transform: translateY(16px); opacity: 0; }
}
::view-transition-new(.entra-abajo) {
animation: aparecer-abajo 250ms ease-out;
}
::view-transition-old(.sale-abajo) {
animation: salir-abajo 200ms ease-in;
}
::view-transition-new(.entra-derecha) {
animation: desde-derecha 280ms ease-out;
}
::view-transition-new(.entra-izquierda) {
animation: desde-izquierda 280ms ease-out;
}
::view-transition-old(.sale-derecha) {
animation: hacia-derecha 220ms ease-in;
}
::view-transition-old(.sale-izquierda) {
animation: hacia-izquierda 220ms ease-in;
}
::view-transition-group(.morfosis) {
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
/* Respeta a quien pide menos movimiento */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Comprueba que tienes la versión adecuada con npm ls react. Este es el mapa del módulo:
| Novedad | Versión | Para qué sirve |
|---|---|---|
<Activity> | 19.2 | Ocultar sin perder estado |
cacheSignal | 19.2 | Cancelar trabajo cuando acaba un render en servidor |
<ViewTransition> | 19.3 | Animar cambios de interfaz |
addTransitionType | 19.3 | Elegir la animación según la causa |
browser() | 19.3 | Renderizar un componente solo en el navegador |
| Trusted Types | 19.3 | Aceptar valores de confianza en más sitios |
| Transiciones independientes | 19.3 | Que una transición lenta no retenga a otra |
2. <Activity>: ocultar sin perder 🆕 19.2
🆕 Desde React 19.2. No existe en React 19.1 ni anteriores, donde tampoco hay una versión
unstable_. Si usas una versión anterior: oculta constyle={{ display: 'none' }}, que conserva estado y DOM pero deja los efectos activos, o eleva el estado al padre si te basta con guardar los datos.
Imagina un panel con pestañas. El usuario filtra empleados, cambia de pestaña, vuelve y el filtro ha desaparecido. La causa es que {activa === 'empleados' && <Empleados />} desmonta el componente, y al desmontarlo React tira su estado.
Hasta ahora tenías dos salidas, y ninguna era buena:
- Elevar el estado al padre. Funciona, pero el padre acaba guardando cosas que no le incumben, y el DOM se sigue perdiendo: el texto de un
<textarea>no controlado, la posición del scroll, un vídeo a medias. - Ocultar con CSS. Conservas todo, pero el componente sigue vivo: sus intervalos siguen en marcha y sus suscripciones siguen recibiendo datos, aunque nadie los vea.
<Activity> es la tercera vía. Sus hijos pueden estar en uno de dos modos:
<Activity mode={abierto ? 'visible' : 'hidden'}>
<Panel />
</Activity>
📘 Concepto: Una Activity oculta se comporta como si sus hijos estuvieran desmontados, con una diferencia: React guarda su estado y su DOM para devolverlos después.
Figura 1. Cada forma de ocultar conserva cosas distintas. Activity guarda estado y DOM, y limpia los efectos.
Las props son dos: mode, que vale 'visible' por defecto, y name, opcional, que identifica la frontera en React DevTools.
Un panel con pestañas
Este ejemplo tiene tres pestañas. Empleados guarda un filtro en estado de React, Notas guarda el texto solo en el DOM, y Reloj tiene un intervalo. Una casilla te deja comparar con el renderizado condicional de siempre:
import { Activity, useCallback, useEffect, useState } from 'react'
const API = 'http://localhost:3000/api'
type Pestana = 'empleados' | 'notas' | 'reloj'
interface Empleado {
empId: number
nombre: string
apellidos: string
oficio: string
}
interface PropsPanel {
anotar: (mensaje: string) => void
}
function Empleados({ anotar }: PropsPanel) {
const [lista, setLista] = useState<Empleado[]>([])
const [filtro, setFiltro] = useState('')
useEffect(() => {
anotar('Empleados: efecto creado, pido los datos')
const control = new AbortController()
fetch(`${API}/empleados?tamano=100`, { signal: control.signal })
.then((r) => r.json())
.then((datos) => setLista(datos.contenido))
.catch(() => {})
return () => {
anotar('Empleados: efecto limpiado')
control.abort()
}
}, [anotar])
const visibles = lista.filter((e) =>
`${e.nombre} ${e.apellidos}`.toLowerCase().includes(filtro.toLowerCase()),
)
return (
<>
<input
aria-label="Filtrar empleados"
placeholder="Filtrar por nombre"
value={filtro}
onChange={(e) => setFiltro(e.target.value)}
/>
<ul className="simple">
{visibles.slice(0, 4).map((e) => (
<li key={e.empId}>
{e.nombre} {e.apellidos} <span className="discreto">{e.oficio}</span>
</li>
))}
</ul>
</>
)
}
function Notas() {
// Sin estado de React: el texto vive solo en el DOM
return <textarea aria-label="Notas" placeholder="Escribe una nota" />
}
function Reloj({ anotar }: PropsPanel) {
const [segundos, setSegundos] = useState(0)
useEffect(() => {
anotar('Reloj: intervalo creado')
const id = setInterval(() => setSegundos((s) => s + 1), 1000)
return () => {
anotar('Reloj: intervalo limpiado')
clearInterval(id)
}
}, [anotar])
return <p>Llevas {segundos} s en esta pestaña.</p>
}
const pestanas: Pestana[] = ['empleados', 'notas', 'reloj']
export default function App() {
const [activa, setActiva] = useState<Pestana>('empleados')
const [conActivity, setConActivity] = useState(true)
const [registro, setRegistro] = useState<string[]>([])
const anotar = useCallback((mensaje: string) => {
setRegistro((r) => [...r.slice(-5), mensaje])
}, [])
function contenido(p: Pestana) {
if (p === 'empleados') return <Empleados anotar={anotar} />
if (p === 'notas') return <Notas />
return <Reloj anotar={anotar} />
}
return (
<section className="tarjeta">
<h2>Panel de EMPRESA</h2>
<label className="fila">
<input
type="checkbox"
checked={conActivity}
onChange={(e) => setConActivity(e.target.checked)}
/>
Conservar las pestañas con Activity
</label>
<div className="fila" role="tablist">
{pestanas.map((p) => (
<button key={p} role="tab" aria-pressed={p === activa} onClick={() => setActiva(p)}>
{p}
</button>
))}
</div>
{conActivity
? pestanas.map((p) => (
<Activity key={p} mode={p === activa ? 'visible' : 'hidden'}>
{contenido(p)}
</Activity>
))
: contenido(activa)}
<h2>Registro</h2>
<pre className="registro">{registro.join('\n')}</pre>
</section>
)
}
Escribe gar en el filtro, pasa a notas, escribe algo, pasa a reloj, espera unos segundos y vuelve a empleados:

Figura 2. El filtro sigue ahí tras cambiar de pestaña. El registro muestra que los efectos sí se han limpiado y recreado.
Esto es lo que hemos medido al hacer ese recorrido:
| Comprobación | Con Activity | Condicional |
|---|---|---|
Filtro al volver a empleados | gar | Vacío |
Texto del <textarea> | Conservado | Se pierde |
| Segundos del reloj | Conserva el valor y se detiene | Vuelve a 0 |
Fíjate en la última línea del registro: Empleados: efecto creado, pido los datos. Al volver a la pestaña se hizo una petición nueva. Es lógico, porque el efecto se destruyó al ocultar y se crea de nuevo al mostrar. Ya sabes del módulo 09 que un efecto debe sincronizar con un sistema externo, y aquí se ve por qué: si está bien escrito, sobrevive a este ciclo sin cambios. Si no quieres repetir la petición, necesitas una caché, como en la solución del ejercicio 2 o con TanStack Query, en el módulo 19.
Qué ocurre exactamente al ocultar
Hemos colocado una sonda con un efecto, un efecto de layout y una ref de función dentro de una Activity. Este es el orden real:
Al ocultar: layout - refcb limpieza efecto - render hijo
Al mostrar: render hijo refcb nodo layout + efecto +
De aquí salen cuatro reglas:
- El DOM recibe
display: none. React lo aplica con un estilo en línea, con!important, sobre los nodos de primer nivel. - Las refs se desconectan. Mientras está oculta, una ref de función recibe su limpieza. No esperes medir nodos de una Activity oculta.
- Todos los efectos se limpian, los de
useLayoutEffectincluidos. - Los hijos siguen renderizando. Si el padre les pasa props nuevas, las reciben y el DOM oculto se actualiza. En la sonda, la prop pasó de 0 a 1 con la Activity oculta, y el DOM mostró
hijo 1. Eso sí, ese trabajo va con menos prioridad que el contenido visible.
💡 Consejo: Strict Mode hace aquí un servicio útil. En desarrollo simula ese desmontaje y montaje de efectos, de modo que un efecto que no sobrevive a una Activity ya falla en Strict Mode. Lo viste en el módulo 05.
Precargar lo que aún no se ve
Una Activity que nace oculta también renderiza a sus hijos, pero sin montar sus efectos. Eso sirve para adelantar trabajo: cuando el usuario entre en la pantalla, ya estará lista.
import { Activity, Suspense, use, useState } from 'react'
const API = 'http://localhost:3000/api'
interface Pedido {
pedidoId: number
fechaPedido: string
estado: string
}
interface Pagina {
contenido: Pedido[]
}
// La promesa vive fuera del componente: debe ser estable entre renders
let promesaPedidos: Promise<Pagina> | null = null
function cargarPedidos() {
promesaPedidos ??= fetch(`${API}/pedidos?tamano=5&orden=-fechaPedido`).then((r) => r.json())
return promesaPedidos
}
function Pedidos() {
const { contenido } = use(cargarPedidos())
return (
<ul className="simple">
{contenido.map((p) => (
<li key={p.pedidoId}>
Pedido {p.pedidoId}, {p.fechaPedido} <span className="discreto">{p.estado}</span>
</li>
))}
</ul>
)
}
export default function App() {
const [verPedidos, setVerPedidos] = useState(false)
return (
<section className="tarjeta">
<h2>Inicio</h2>
<div className="fila">
<button aria-pressed={!verPedidos} onClick={() => setVerPedidos(false)}>
Resumen
</button>
<button aria-pressed={verPedidos} onClick={() => setVerPedidos(true)}>
Pedidos
</button>
</div>
{!verPedidos && <p>Bienvenido al panel. Los pedidos ya se están cargando.</p>}
<Activity mode={verPedidos ? 'visible' : 'hidden'}>
<Suspense fallback={<p className="discreto">Cargando pedidos...</p>}>
<Pedidos />
</Suspense>
</Activity>
</section>
)
}
Lo que medimos: al cargar la página ya hay una petición a /pedidos, aunque nadie la haya pedido. Al pulsar el botón, los datos aparecen en 14 ms y sin pasar por el mensaje de carga.

Figura 3. Los pedidos aparecen sin mensaje de carga, porque se habían pedido antes de mostrar la pestaña.
⚠️ Importante: Esto solo funciona con datos que activan Suspense, como una promesa leída con
use(lo viste en el módulo 15). Una petición dentro deuseEffectno se precarga, porque los efectos no se montan mientras la Activity está oculta.
Efectos del DOM que persisten
Ocultar no apaga lo que hace el propio DOM. Un <audio> o un <video> que suena sigue sonando con display: none. Lo hemos comprobado: al ocultar un <audio> en reproducción, su propiedad paused sigue en false. Con esta limpieza pasa a true:
function Video({ origen }: { origen: string }) {
const ref = useRef<HTMLVideoElement>(null)
useLayoutEffect(() => {
const video = ref.current!
return () => video.pause()
}, [])
return <video ref={ref} src={origen} controls />
}
La documentación recomienda useLayoutEffect y no useEffect, porque esta limpieza está ligada al momento en que la interfaz se oculta, y un efecto normal podría retrasarse.
Una Activity con solo texto
Si los hijos de una Activity oculta solo devuelven texto, no hay ningún elemento al que aplicar display: none, así que React no pinta nada. En nuestra prueba, el contenedor queda vacío. Con mode="visible" el texto se ve con normalidad.
Hidratación selectiva
Las fronteras de Activity también dividen el árbol en unidades que se hidratan por separado, igual que Suspense. Por eso, en una página renderizada en el servidor, los botones de las pestañas pueden responder antes de que se hidrate el contenido oculto. Verás qué es hidratar en el módulo 23.
¿Cuándo usar cada opción?
| Situación | Elige |
|---|---|
| Pestañas o paneles a los que el usuario vuelve | <Activity> |
| Pantalla probable que quieres dejar preparada | <Activity mode="hidden"> |
| Contenido que no volverá a mostrarse | Renderizado condicional |
| Solo ocultar sin cambiar nada de los efectos | CSS |
| Listas enormes en pestañas | Con cuidado: el DOM oculto sigue ocupando memoria |
3. cacheSignal: cancelar trabajo sobrante 🆕 19.2
🆕 Desde React 19.2. No existe en React 19.1 ni anteriores. Si usas una versión anterior: crea tu propio
AbortControllery llama aabort()cuando sepas que el trabajo ya no sirve. No hay forma directa de enterarse de cuándo acaba el render en servidor.
Este apartado adelanta algo del módulo 23, porque cacheSignal solo sirve hoy en Server Components. Si aún no los conoces, quédate con la idea: son componentes que se ejecutan en el servidor, y un mismo render puede lanzar muchas peticiones.
cache memoriza una función durante un render de servidor. cacheSignal devuelve un AbortSignal que se aborta cuando ese render termina, sea porque ha acabado, porque se ha cancelado o porque ha fallado. Así, una petición que ya nadie va a usar no sigue consumiendo recursos.
import { cache, cacheSignal } from 'react'
const API = 'http://localhost:3000/api'
const pedir = cache(async (ruta: string) => {
const senal = cacheSignal() as AbortSignal | null
const respuesta = await fetch(`${API}/${ruta}`, { signal: senal, cache: 'no-store' })
return respuesta.json()
})
export default async function Resumen() {
const datos = await pedir('estadisticas')
return <pre>{JSON.stringify(datos)}</pre>
}
Lo hemos probado en un Server Component con Next.js 16.4.3, que incluye su propia copia de React. Esto es lo que ocurre:
| Dónde se llama | Devuelve |
|---|---|
| Durante el render de un Server Component | Un AbortSignal, sin abortar |
| Después de terminar el render | La señal pasa a abortada, con el motivo This render completed successfully |
| En un Client Component, durante el render | null |
| En un Client Component, dentro de un efecto | null |
Tres avisos:
- Usa el encadenamiento opcional. Como puede devolver
null, comprueba concacheSignal()?.aborted. La documentación avisa de que el valor en el cliente puede cambiar en el futuro, así que no des por hecho que siempre seránull. - Solo cancela lo que empieza durante el render. Una petición lanzada fuera del componente, al cargar el módulo, no se cancela.
- El tipo no ayuda. En
@types/react19.3.0,CacheSignales una interfaz vacía, y por eso el ejemplo necesitaas AbortSignal | nullpara pasar la señal afetch.
Si una función falla porque el render se canceló, no lo registres como error:
try {
return await consultar(id)
} catch (error) {
if (!cacheSignal()?.aborted) registrar(error)
return null
}
4. <ViewTransition>: animar cambios de interfaz 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: llama tú a
document.startViewTransitiony aplica el cambio dentro conflushSync, como se explica en el apartado 13. Pierdes la integración con transiciones, Suspense y nombres automáticos.
Animar el paso de una pantalla a otra siempre ha sido difícil en React. Cuando un elemento desaparece, sale del DOM y ya no hay nada que animar. Librerías como Motion resuelven eso con mucho código. Los navegadores ofrecen ahora la View Transition API: el navegador toma una foto de la pantalla, deja que cambies el DOM, y anima de la foto a lo nuevo.
<ViewTransition> conecta esa API con React. Tú marcas qué partes se animan, y React decide cuándo llamar a startViewTransition. No debes llamarla tú.
import { ViewTransition, startTransition, useState } from 'react'
function Ficha() {
return (
<ViewTransition>
<article className="tarjeta">
<h2>Ana García López</h2>
<p>Directora general, departamento 10.</p>
</article>
</ViewTransition>
)
}
export default function App() {
const [visible, setVisible] = useState(false)
return (
<>
<div className="fila">
<button onClick={() => startTransition(() => setVisible((v) => !v))}>
Con transición
</button>
<button onClick={() => setVisible((v) => !v)}>Sin transición</button>
</div>
{visible && <Ficha />}
</>
)
}
Pulsa los dos botones. Con transición, la ficha aparece suavemente. Sin transición, aparece de golpe. Medimos las llamadas a document.startViewTransition: una con el primer botón, ninguna con el segundo.

Figura 4. La ficha congelada a media animación. La animación por defecto es un fundido.
📘 Concepto: Un
setStatenormal es urgente: debe verse ya. Una animación tarda, y por eso React solo anima lo que no es urgente. Se activa con actualizaciones dentro destartTransition, con Actions, conuseDeferredValuey cuando Suspense revela contenido. RepasastartTransitionen el módulo 15.
Qué ocurre por debajo
Figura 5. React prepara los nombres, el navegador captura la pantalla, React cambia el DOM y el navegador anima.
Durante la animación, el navegador superpone a la página un árbol de pseudoelementos. Hay uno de ::view-transition-group por cada elemento con nombre, que se encarga de moverlo y escalarlo. Dentro, ::view-transition-old es la foto del estado anterior, y ::view-transition-new es la imagen en vivo del nuevo.
De aquí sale una consecuencia importante: se anima una imagen, no tus elementos. No se reacomoda cada párrafo por dentro, sino que la foto se mueve, se escala y se funde. Si quieres que una parte se mueva por su cuenta, necesita su propio <ViewTransition>.
Las props
| Prop | Tipo | Para qué |
|---|---|---|
name | string | Nombre fijo, solo para elementos compartidos |
enter, exit, update, share | Clase, 'auto', 'none' u objeto | Animación de cada disparador |
default | Igual que las anteriores | Valor para los que no indiques |
onEnter, onExit, onUpdate, onShare | Función | Animar con JavaScript |
5. Los cuatro disparadores 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: distingue tú los casos con
document.startViewTransition, poniendo y quitandoview-transition-namea mano, como en el apartado 13.
React decide solo qué tipo de animación corresponde, según cómo ha cambiado el árbol:
Figura 6. enter y exit dependen de montar y desmontar. update de un cambio dentro. share de dos límites con el mismo nombre.
enter: el<ViewTransition>se monta en esta transición.exit: se desmonta.update: React cambia algo del DOM de dentro, o el límite se mueve o cambia de tamaño porque ha cambiado un hermano.share: un límite con nombre se desmonta y otro con el mismo nombre se monta en la misma transición.
La regla de colocación
⚠️ Importante:
enteryexitsolo se activan si el<ViewTransition>va antes de cualquier nodo del DOM dentro de lo que se monta. Es el fallo más habitual con esta API.
// ❌ El <div> se monta primero: no hay enter
{visible && (
<div>
<ViewTransition>
<p>Hola</p>
</ViewTransition>
</div>
)}
// ✅ El límite es lo primero que se monta
{visible && (
<ViewTransition>
<div>
<p>Hola</p>
</div>
</ViewTransition>
)}
Lo hemos comprobado montando ambos: el segundo produce una animación ::view-transition-new para el nuevo elemento, y el primero no produce ninguna.
Varios hijos
Si un <ViewTransition> tiene varios nodos de primer nivel, cada uno recibe su propio nombre. Con dos párrafos medimos _t_5_ y _t_5__1, que se animan por separado.
Apagar lo que no quieres
Por defecto, un límite se anima por cualquier motivo. Con default="none" apagas todo, y luego enciendes lo que necesites:
<ViewTransition default="none" enter="auto" exit="auto">
Y un límite interior puede excluir una parte de la animación de su padre:
<ViewTransition>
<div className={tema}>
<ViewTransition update="none">{children}</ViewTransition>
</div>
</ViewTransition>
6. Personalizar con clases CSS 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: asigna
view-transition-classoview-transition-nameen tu CSS y usadocument.startViewTransition. Los selectores::view-transition-*funcionan igual.
El fundido por defecto se cambia con una View Transition Class. Pasas un nombre de clase a la prop del disparador:
<ViewTransition enter="entra-abajo" exit="sale-abajo">
<aside>...</aside>
</ViewTransition>
Y en el CSS la seleccionas dentro de los pseudoelementos, con punto. Estas reglas ya están en tu index.css:
@keyframes aparecer-abajo {
from { transform: translateY(16px); opacity: 0; }
}
::view-transition-new(.entra-abajo) {
animation: aparecer-abajo 250ms ease-out;
}
En enter no hay nada antiguo, así que anima ::view-transition-new. En exit no hay nada nuevo, y anima ::view-transition-old. En update y share existen los dos, además del grupo que los mueve.
💡 Consejo: Muchos ejemplos antiguos seleccionan por nombre, como
::view-transition-old(mi-nombre). La documentación de React recomienda las clases, porque el nombre debe ser único y una clase se reutiliza.
Activity con View Transition
Las dos novedades encajan. Si un <ViewTransition> está dentro de una Activity que se muestra en una transición, se activa enter, y si se oculta, exit. Medimos las animaciones aparecer-abajo al mostrar y salir-abajo al ocultar. La solución del ejercicio 3 lo aplica.
7. addTransitionType: animar según la causa 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: guarda la dirección en una variable de estado antes de navegar, o pásala a
document.startViewTransition({ update, types })si llamas tú a esa API.
Dos botones pueden provocar el mismo cambio de estado, y aun así pedir animaciones distintas. Al pasar de página, “Siguiente” debería deslizar hacia la izquierda, y “Anterior”, al revés. addTransitionType etiqueta la causa dentro de startTransition, y las props aceptan un objeto indexado por esas etiquetas:
import { Suspense, ViewTransition, addTransitionType, startTransition, use, useState } from 'react'
const API = 'http://localhost:3000/api'
const TAMANO = 5
interface Empleado {
empId: number
nombre: string
apellidos: string
oficio: string
}
interface Pagina {
contenido: Empleado[]
paginas: number
}
const cache = new Map<number, Promise<Pagina>>()
function cargarPagina(pagina: number) {
let promesa = cache.get(pagina)
if (!promesa) {
const url = `${API}/empleados?orden=apellidos&tamano=${TAMANO}&pagina=${pagina}`
promesa = fetch(url).then((r) => r.json() as Promise<Pagina>)
cache.set(pagina, promesa)
}
return promesa
}
function Tabla({ pagina }: { pagina: number }) {
const { contenido } = use(cargarPagina(pagina))
return (
<ul className="simple">
{contenido.map((e) => (
<li key={e.empId}>
{e.apellidos}, {e.nombre} <span className="discreto">{e.oficio}</span>
</li>
))}
</ul>
)
}
export default function App() {
const [pagina, setPagina] = useState(1)
const total = use(cargarPagina(1)).paginas
function irA(destino: number, direccion: 'siguiente' | 'anterior') {
startTransition(() => {
addTransitionType(direccion)
setPagina(destino)
})
}
return (
<section className="tarjeta">
<h2>Empleados</h2>
<div className="fila">
<button disabled={pagina === 1} onClick={() => irA(pagina - 1, 'anterior')}>
Anterior
</button>
<span>
Página {pagina} de {total}
</span>
<button disabled={pagina === total} onClick={() => irA(pagina + 1, 'siguiente')}>
Siguiente
</button>
</div>
<Suspense fallback={<div className="esqueleto" />}>
<ViewTransition
key={pagina}
default="none"
enter={{ siguiente: 'entra-derecha', anterior: 'entra-izquierda', default: 'none' }}
exit={{ siguiente: 'sale-izquierda', anterior: 'sale-derecha', default: 'none' }}
>
<div>
<Tabla pagina={pagina} />
</div>
</ViewTransition>
</Suspense>
</section>
)
}
Tres detalles del ejemplo:
key={pagina}hace que cada página sea un límite distinto. Por eso sale uno (exit) y entra otro (enter). Sinkey, sería unupdatedel mismo límite.'default'es la entrada que se usa cuando no coincide ningún tipo.- La promesa se resuelve dentro de la transición: la página antigua se queda en pantalla hasta que llegan los datos, y la animación ocurre después, con el tipo intacto.

Figura 7. Al pulsar Anterior, las dos páginas se cruzan. En el navegador esto se percibe como un deslizamiento.
Medimos que siguiente usa hacia-izquierda y desde-derecha, y anterior, las contrarias. El navegador recibe los tipos en startViewTransition, así que también puedes seleccionarlos solo con CSS:
:root:active-view-transition-type(siguiente) {
&::view-transition-new(*) {
animation-duration: 400ms;
}
}
Reglas de la documentación:
- Si se combinan varias transiciones, se reúnen todos sus tipos, y puedes añadir más de uno.
- Si coinciden varios tipos, las clases se unen. Si alguno vale
'none', gana y ese límite no se anima. - Los tipos se borran tras cada commit. Un
fallbackde Suspense los recibe, pero la revelación posterior del contenido no.
8. Elementos compartidos con share 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: pon el mismo
view-transition-nameen los dos elementos, uno antes y otro después del cambio, dentro dedocument.startViewTransitionconflushSync.
La animación más llamativa es la de un elemento que parece viajar de una pantalla a otra: la miniatura de la lista crece hasta ser la imagen del detalle. Son dos elementos distintos, pero con el mismo name.
import { ViewTransition, startTransition, useEffect, useState } from 'react'
const API = 'http://localhost:3000/api'
interface Producto {
productoId: number
nombre: string
descripcion: string
precio: number
}
function Miniatura({ producto }: { producto: Producto }) {
return (
<ViewTransition name={`producto-${producto.productoId}`} share="morfosis">
<div className="miniatura">{producto.nombre[0]}</div>
</ViewTransition>
)
}
export default function App() {
const [productos, setProductos] = useState<Producto[]>([])
const [elegido, setElegido] = useState<Producto | null>(null)
useEffect(() => {
fetch(`${API}/productos?tamano=6`)
.then((r) => r.json())
.then((datos) => setProductos(datos.contenido))
}, [])
function abrir(producto: Producto | null) {
startTransition(() => setElegido(producto))
}
if (elegido) {
return (
<section className="tarjeta">
<div className="detalle">
<Miniatura producto={elegido} />
<div>
<h2>{elegido.nombre}</h2>
<p>{elegido.descripcion}</p>
<p>{elegido.precio} €</p>
<button onClick={() => abrir(null)}>Volver al catálogo</button>
</div>
</div>
</section>
)
}
return (
<section className="tarjeta">
<h2>Catálogo</h2>
<div className="rejilla">
{productos.map((p) => (
<button key={p.productoId} onClick={() => abrir(p)}>
<Miniatura producto={p} />
{p.nombre}
</button>
))}
</div>
</section>
)
}

Figura 8. La miniatura mientras viaja hacia la posición del detalle.
El nombre incluye el identificador porque cada nombre debe ser único en toda la aplicación. Con dos iguales montados a la vez, React escribe en consola There are two <ViewTransition name=%s> components with the same name mounted at the same time, y la transición falla: en nuestra prueba, el navegador la rechazó con InvalidStateError.
Los elementos compartidos no están sujetos a la regla de colocación: aquí, en la rejilla, el límite está dentro de un <button> y funciona.
⚠️ Importante: No se forma la pareja si uno de los dos lados queda fuera de la ventana visible, ni si la transición muestra primero un fallback de Suspense antes de montar el nuevo lado. Además,
namees solo para esto: en los demás casos deja que React genere el nombre.
onShare tiene prioridad sobre onEnter y onExit, y solo se dispara un evento por límite en cada transición.
9. Reordenar listas 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: asigna un
view-transition-namedistinto a cada fila y ordena dentro dedocument.startViewTransitionconflushSync.
Para animar un reordenamiento, cada elemento necesita su propio límite, con key en el <ViewTransition>:
import { ViewTransition, startTransition, useEffect, useState } from 'react'
const API = 'http://localhost:3000/api'
interface Empleado {
empId: number
nombre: string
apellidos: string
salario: number
}
type Orden = 'apellidos' | 'salario'
export default function App() {
const [empleados, setEmpleados] = useState<Empleado[]>([])
const [orden, setOrden] = useState<Orden>('apellidos')
useEffect(() => {
fetch(`${API}/empleados?tamano=6`)
.then((r) => r.json())
.then((datos) => setEmpleados(datos.contenido))
}, [])
const ordenados = [...empleados].sort((a, b) =>
orden === 'salario' ? b.salario - a.salario : a.apellidos.localeCompare(b.apellidos),
)
return (
<section className="tarjeta">
<h2>Empleados</h2>
<div className="fila">
{(['apellidos', 'salario'] as const).map((o) => (
<button
key={o}
aria-pressed={o === orden}
onClick={() => startTransition(() => setOrden(o))}
>
Por {o}
</button>
))}
</div>
<ul className="simple">
{ordenados.map((e) => (
<ViewTransition key={e.empId}>
<li>
{e.apellidos}, {e.nombre} <span className="discreto">{e.salario} €</span>
</li>
</ViewTransition>
))}
</ul>
</section>
)
}

Figura 9. Cada fila viaja a su nueva posición. Una sola llamada a startViewTransition anima las seis.
Si envolvieras toda la lista en un único límite, no verías moverse las filas: verías un fundido de la lista completa. Cada elemento que deba moverse por su cuenta necesita su frontera.
10. Suspense y View Transitions 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: no hay alternativa directa, porque tu código no se entera del momento de la revelación. Puedes animar el contenido con CSS al montarse.
Un <ViewTransition> encima de una frontera de Suspense anima la revelación, de modo que el fallback se funde en el contenido:
import { Suspense, ViewTransition, use, useState } from 'react'
const API = 'http://localhost:3000/api'
interface Estadisticas {
empleados: number
productos: number
pedidos: number
}
let promesa: Promise<Estadisticas> | null = null
function cargar() {
promesa ??= fetch(`${API}/estadisticas`).then((r) => r.json())
return promesa
}
function Resumen() {
const { empleados, productos, pedidos } = use(cargar())
return (
<ul className="simple">
<li>{empleados} empleados</li>
<li>{productos} productos</li>
<li>{pedidos} pedidos</li>
</ul>
)
}
export default function App() {
const [mostrar, setMostrar] = useState(false)
return (
<section className="tarjeta">
<h2>Estadísticas</h2>
<div className="fila">
<button onClick={() => setMostrar(true)}>Cargar</button>
</div>
{mostrar && (
<ViewTransition update="auto" default="none">
<Suspense fallback={<div className="esqueleto" />}>
<Resumen />
</Suspense>
</ViewTransition>
)}
</section>
)
}
| Tras pulsar | Tras recibir los datos |
|---|---|
![]() | ![]() |
Figura 10. El fallback aparece sin animación, porque el botón no usa una transición. Después, la revelación sí se anima.
Aquí setMostrar no va dentro de una transición, y aun así hay animación: una sola llamada a startViewTransition, justo al revelar. Es lo que recomienda el equipo de React: el fallback aparece de inmediato y sin animar, y la llegada del contenido sí se anima. Por eso el ejemplo usa update="auto" default="none".
Hay dos colocaciones, y hacen cosas distintas:
// update: el fallback se funde en el contenido
<ViewTransition>
<Suspense fallback={<A />}>
<B />
</Suspense>
</ViewTransition>
// exit y enter: A sale y B entra, cada uno con su animación
<Suspense fallback={<ViewTransition><A /></ViewTransition>}>
<ViewTransition><B /></ViewTransition>
</Suspense>
La segunda forma es la del ejercicio 5. Según la documentación, <ViewTransition> también espera hasta 500 ms a que carguen las fuentes nuevas y las imágenes visibles, para que no aparezcan de golpe cuando ya ha empezado la animación.
11. Animar con JavaScript 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: espera a la promesa
readydedocument.startViewTransitiony anima condocument.documentElement.animatey la opciónpseudoElement.
Cuando el CSS no basta, los eventos te dan los pseudoelementos para usar la Web Animations API. Reciben la instancia y los tipos, y deben devolver una limpieza:
import { ViewTransition, addTransitionType, startTransition, useState } from 'react'
import type { ViewTransitionInstance } from 'react'
function animarEntrada(instancia: ViewTransitionInstance, tipos: string[]) {
const duracion = tipos.includes('rapido') ? 150 : 600
const animacion = instancia.new.animate(
[
{ transform: 'scale(0.8)', opacity: 0 },
{ transform: 'scale(1)', opacity: 1 },
],
{ duration: duracion, easing: 'ease-out' },
)
return () => animacion.cancel()
}
export default function App() {
const [visible, setVisible] = useState(false)
function alternar(rapido: boolean) {
startTransition(() => {
if (rapido) addTransitionType('rapido')
setVisible((v) => !v)
})
}
return (
<>
<div className="fila">
<button onClick={() => alternar(false)}>Normal</button>
<button onClick={() => alternar(true)}>Rápido</button>
</div>
{visible && (
<ViewTransition default="none" enter="auto" onEnter={animarEntrada}>
<p className="tarjeta">Nuevo aviso: hay 3 pedidos pendientes.</p>
</ViewTransition>
)}
</>
)
}
Medimos 150 ms con Rápido y 600 ms sin él, así que los tipos llegan al evento.
Dos detalles que no son evidentes:
default="none"apaga también el evento: por eso hay que indicarenter="auto".- Con
'auto'sigue activo el fundido del navegador, que convive con tu animación. Si quieres solo la tuya, usa una clase conanimation: noneen el pseudoelemento.
En TypeScript, @types/react declara solo name en ViewTransitionInstance. Las propiedades old, new, group e imagePair están en @types/react-dom. Con la plantilla ya se ven, pero si no aparecen, añade import type {} from 'react-dom', igual que con Fragment Refs en el módulo 10.
12. View Transitions con React Router 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: usa la prop
viewTransitionde<Link>, que llama adocument.startViewTransitionpor su cuenta, aunque React Router 8 exige React 19.2.7 o superior.
Si tu enrutador hace las navegaciones dentro de startTransition, <ViewTransition> funciona sin más. React Router 8 lo hace por defecto. Aquí se adelanta lo mínimo, y el módulo 18 lo explica todo. Instala react-router con npm install react-router, que exige React 19.2.7 o superior.
import { Suspense, ViewTransition } from 'react'
import {
Link,
Outlet,
RouterProvider,
createBrowserRouter,
useLoaderData,
useLocation,
} from 'react-router'
const API = 'http://localhost:3000/api'
interface Departamento {
deptId: number
nombre: string
localidad: string
}
function Marco() {
const { pathname } = useLocation()
return (
<section className="tarjeta">
<nav className="fila">
<Link to="/">Inicio</Link>
<Link to="/departamentos">Departamentos</Link>
</nav>
<Suspense fallback={<p>Cargando...</p>}>
<ViewTransition key={pathname} default="none" enter="entra-derecha" exit="sale-izquierda">
<div>
<Outlet />
</div>
</ViewTransition>
</Suspense>
</section>
)
}
function Inicio() {
return <h2>Bienvenido a EMPRESA</h2>
}
function Departamentos() {
const departamentos = useLoaderData<Departamento[]>()
return (
<ul className="simple">
{departamentos.map((d) => (
<li key={d.deptId}>
{d.nombre} <span className="discreto">{d.localidad}</span>
</li>
))}
</ul>
)
}
const rutas = createBrowserRouter([
{
element: <Marco />,
children: [
{ path: '/', element: <Inicio /> },
{
path: '/departamentos',
element: <Departamentos />,
loader: async () => {
const respuesta = await fetch(`${API}/departamentos`)
const datos = await respuesta.json()
return datos.contenido as Departamento[]
},
},
],
},
])
export default function App() {
return <RouterProvider router={rutas} />
}
Lo que medimos con React Router 8.5.1:
| Acción | Resultado |
|---|---|
Pulsar un Link | Sí anima: hacia-izquierda y desde-derecha |
| Botón Atrás del navegador | No anima: ninguna llamada a startViewTransition |
No es un fallo de tu código. La documentación de React lo explica: una transición iniciada desde el antiguo evento popstate debe acabar de forma síncrona, así que React se salta la animación. La solución que propone es que el enrutador use la Navigation API.
⚠️ Importante:
<Link viewTransition>de React Router es otra cosa: el enrutador llama por su cuenta adocument.startViewTransition, sin pasar por<ViewTransition>. Usa una de las dos vías, no ambas. Además, la propuseTransitions={false}de<RouterProvider>evitastartTransition, y sin transición no hay animación.
Si escribes tu propio enrutador, la documentación añade un requisito: React espera a que acabe la navegación pendiente, y si esta depende de React, debes desbloquearla en useLayoutEffect. Con useEffect habría un bloqueo.
13. Soporte, accesibilidad y versiones anteriores
Navegadores
MDN indica que startViewTransition es Baseline 2025: funciona en las últimas versiones de los navegadores principales desde octubre de 2025. Hemos simulado un navegador sin esa API: React no da ningún error, y el cambio se aplica sin animación. Por eso es una mejora progresiva segura. Hoy solo funciona en el DOM, no en React Native.
Movimiento reducido
Hay personas a las que las animaciones les marean. Respeta siempre su preferencia, como hace el final del index.css:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Con prefers-reduced-motion activado medimos cero animaciones sobre los pseudoelementos, y la interfaz cambia igual.
Sin <ViewTransition>
En versiones anteriores puedes usar la API del navegador directamente. flushSync obliga a React a escribir el DOM dentro de la función, que es lo que el navegador necesita para tomar la segunda imagen:
import { useState } from 'react'
import { flushSync } from 'react-dom'
export default function App() {
const [visible, setVisible] = useState(false)
function alternar() {
if (!document.startViewTransition) {
setVisible((v) => !v)
return
}
document.startViewTransition(() => {
flushSync(() => setVisible((v) => !v))
})
}
return (
<>
<div className="fila">
<button onClick={alternar}>Alternar</button>
</div>
{visible && (
<article className="tarjeta" style={{ viewTransitionName: 'ficha' }}>
<h2>Ana García López</h2>
</article>
)}
</>
)
}
Funciona, pero fíjate en lo que pierdes: pones los nombres a mano, debes comprobar el soporte, flushSync hace la actualización síncrona y no se integra con Suspense.
14. browser(): solo en el navegador 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: usa un estado
montadoque se activa enuseEffecty pinta un sustituto hasta entonces. Pierdes el avisoonBrowserBailouty haces un render de más.
Este apartado también mira al módulo 23. Si tu aplicación se renderiza en el servidor, algunos componentes no pueden hacerlo: usan localStorage, window o la zona horaria del usuario. Lo habitual era esto:
const [montado, setMontado] = useState(false)
useEffect(() => setMontado(true), [])
if (!montado) return <p>Cargando...</p>
Es código repetitivo, y hace un render extra. browser(), de react-dom, sustituye a todo eso:
import { Suspense, use, useState } from 'react'
import { browser } from 'react-dom'
function Borrador() {
use(browser('El borrador se guarda en localStorage.'))
const [texto, setTexto] = useState(() => localStorage.getItem('borrador') ?? '')
function cambiar(valor: string) {
setTexto(valor)
localStorage.setItem('borrador', valor)
}
return (
<textarea
aria-label="Borrador"
value={texto}
onChange={(e) => cambiar(e.target.value)}
/>
)
}
export default function Pagina() {
return (
<main className="tarjeta">
<h2>Nueva incidencia</h2>
<p>Este texto sí viene en el HTML del servidor.</p>
<Suspense fallback={<p className="discreto">Preparando el borrador...</p>}>
<Borrador />
</Suspense>
</main>
)
}
En el servidor, use(browser()) detiene el componente y deja el fallback de la frontera de Suspense más cercana. En el navegador devuelve undefined, y el componente sigue con normalidad. Compara las dos capturas: la primera es la página con JavaScript desactivado, y la segunda, ya hidratada, con el borrador recuperado al recargar.
| HTML del servidor | Tras hidratar |
|---|---|
![]() | ![]() |
Figura 11. El servidor envía el fallback. El navegador lo sustituye por el componente real.
Para saber qué componentes se quedan fuera del servidor, usa onBrowserBailout. Esta es la parte del servidor, con Node:
const { pipe } = renderToPipeableStream(<Pagina />, {
onAllReady() {
pipe(respuesta)
},
onBrowserBailout(error, info) {
console.log((error as Error).cause, info.componentStack)
},
})
Así queda en la consola del servidor:
bailout: Browser-only rendering was requested by `browser()`. | causa: El borrador se guarda en localStorage.
pila: at Borrador
Lo que comprobamos:
- El motivo no viaja en el HTML, como indica la documentación. Es solo para tus registros.
- Al hidratar no hubo errores:
onRecoverableErrorno se llamó. - Sin frontera de Suspense, el render de servidor falla. Se llama a
onShellErrorconThe server render could not complete because client rendering was requested outside a Suspense boundary.
Otras reglas de la documentación: browser() por sí solo no hace nada, hay que pasarlo a use; no debes lanzarlo con throw; y debe llamarse desde un Client Component, no desde un Server Component. El argumento es opcional, y puede ser una cadena o una función. También sirve como motivo para abortar un render lento: abort(browser('El servidor ha tardado demasiado')).
Como use admite condiciones, puedes decidir sobre la marcha: es lo que pide el ejercicio 6.
15. Trusted Types 🔄 19.3
🔄 Cambia en React 19.3.
dangerouslySetInnerHTMLya aceptaba objetosTrustedHTMLantes, perosrcDocno. Si usas una versión anterior: usadangerouslySetInnerHTMLcon una política, que funciona igual desde 18.3, y asignasrcdoccon una ref en lugar de la prop.
En el módulo 02 viste que dangerouslySetInnerHTML es la puerta de entrada típica de los ataques XSS. Trusted Types es una defensa del navegador. Activas una cabecera CSP, y desde ese momento los puntos peligrosos del DOM, como innerHTML, rechazan las cadenas. Solo admiten objetos creados por una política, que es una función tuya.
La ventaja es que puedes auditar el código: en lugar de revisar cada innerHTML, revisas las políticas. La cabecera es esta (aquí, en index.html para probarla):
<meta
http-equiv="Content-Security-Policy"
content="require-trusted-types-for 'script'; trusted-types empresa"
/>
En producción, envíala como cabecera HTTP real. El segundo trozo limita qué nombres de política se pueden crear.
Una política en acción
TypeScript 7 no trae los tipos de trustedTypes en su lib.dom, aunque @types/react sí declara un TrustedHTML vacío y acepta __html: string | TrustedHTML. Puedes instalar @types/trusted-types, o declarar lo mínimo en src/trusted-types.d.ts:
interface PoliticaConfianza {
createHTML(texto: string): TrustedHTML
}
interface Window {
trustedTypes?: {
createPolicy(
nombre: string,
reglas: { createHTML: (texto: string) => string },
): PoliticaConfianza
}
}
Y este es el ejemplo: admite **negrita** y escapa todo lo demás. El escapar es deliberadamente simple, y en un proyecto real usarías una librería como DOMPurify dentro de la política.
import { useMemo, useState } from 'react'
function escapar(texto: string) {
return texto
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}
// Único punto de la aplicación que convierte texto en HTML
const politica = window.trustedTypes!.createPolicy('empresa', {
createHTML(texto) {
return escapar(texto).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
},
})
function Nota({ texto }: { texto: string }) {
const html = useMemo(() => politica.createHTML(texto), [texto])
return <p dangerouslySetInnerHTML={{ __html: html }} />
}
export default function App() {
const [texto, setTexto] = useState('Pedido **urgente** <img src=x onerror=alert(1)>')
const [contador, setContador] = useState(0)
return (
<section className="tarjeta">
<h2>Nota de pedido</h2>
<textarea aria-label="Nota" value={texto} onChange={(e) => setTexto(e.target.value)} />
<Nota texto={texto} />
<button onClick={() => setContador((c) => c + 1)}>Repintar ({contador})</button>
</section>
)
}

Figura 12. La negrita funciona, y el intento de ataque con <img onerror> se muestra como texto.
Qué dice el blog y qué ocurre de verdad
El blog de React 19.3 afirma que antes React convertía siempre los valores a cadena con '' + value, lo que anulaba los objetos de confianza. La prueba en navegador matiza eso. Bajo la cabecera anterior, en Chromium 156:
| Caso | 18.3.1 | 19.0.9 a 19.2.9 | 19.3.1 |
|---|---|---|---|
__html con TrustedHTML | Funciona | Funciona | Funciona |
__html con cadena | Rechazado | Rechazado | Rechazado |
<iframe srcDoc> con TrustedHTML | Rechazado | Rechazado | Funciona |
Elemento <script> creado por React | Rechazado | Rechazado | Rechazado |
Así que innerHTML no había dejado de funcionar: dangerouslySetInnerHTML ya aceptaba estos objetos. El cambio real está en los atributos, como srcDoc, que antes se convertían a cadena.
⚠️ Importante: Un
<script>en JSX sigue siendo rechazado en 19.3.1, incluso conTrustedScriptURLy conasync. La causa es interna: la violación registrada es sobreinnerHTMLcon el valor<script></script>, porque React crea el elemento así. Si necesitas cargar un script en una página con Trusted Types, créalo tú condocument.createElementy asigna asrcun valor de tu política.
Conserva el objeto entre renders
Hay un segundo cambio. Medimos un re-render del padre que deja __html igual, comprobando si el nodo interior seguía siendo el mismo:
__html | 18.3.1 | 19.2.9 | 19.3.1 |
|---|---|---|---|
| Mismo objeto entre renders | Se conserva | Se reasigna | Se conserva |
| Objeto nuevo con el mismo texto | Se reasigna | Se reasigna | Se reasigna |
React compara por identidad, y cada llamada a createHTML devuelve un objeto nuevo. Si la llamas en cada render, destruyes y recreas ese DOM, y pierdes por ejemplo la selección de texto. Por eso el ejemplo usa useMemo: el botón Repintar no toca el nodo.
16. Transiciones independientes y otros cambios de 19.3
Transiciones independientes 🔄 19.3
🔄 Cambia en React 19.3. Hasta React 19.2 las transiciones simultáneas se agrupan y una lenta retiene a las demás. Si usas una versión anterior: no hay alternativa directa. Evita lanzar a la vez transiciones sin relación, o saca de la transición la que deba verse enseguida.
Este cambio ya está medido en el apartado 3 del módulo 15: una transición rápida pasa de unos 1000 ms de espera a 7 ms. Ten en cuenta que esa independencia no incluye las Actions asíncronas: como se midió en el módulo 16, dos Actions de 200 y 1000 ms se publican juntas al acabar la última en 19.0.9, 19.2.9 y 19.3.1.
Otros cambios pequeños
| Cambio | Versión | Qué supone | Dónde |
|---|---|---|---|
submitter en el evento submit | 🆕 19.3 | e.submitter indica qué botón envió el formulario. Medido: undefined en 19.2.9. Antes, usa e.nativeEvent.submitter | Módulo 08 |
onFullscreenChange y onFullscreenError | 🆕 19.3 | Medido: onFullscreenChange se dispara en 19.3.1. No está en @types/react 19.3.0 | Aquí |
| Doble efecto al hidratar en Strict Mode | 🔄 19.3 | Corrige una regresión de 19.0 a 19.2 | Módulo 09 |
useEffectEvent en memo y forwardRef | 🔄 19.3 | Ya lee valores actuales | Módulo 09 |
| Fragment Refs | 🆕 19.3 | Refs a un grupo de nodos | Módulo 10 |
Aviso por use en condicional | 🔄 19.3 | Aviso nuevo; allí se mide cuándo aparece | Módulo 15 |
<Context> en Server Components | 🆕 19.3 | Sin componente envoltorio | Módulo 23 |
onReset tras una Server Action | 🔄 19.3 | Se dispara al reiniciar el formulario | Módulo 23 |
useSyncExternalStore en Activity oculta | 🔄 19.3 | Según el registro de cambios, ya no se pierden cambios del almacén | Módulo 12 |
17. Caso completo: panel de EMPRESA 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: quita
<ViewTransition>yaddTransitionType. El panel sigue funcionando sin animaciones desde 19.2, y antes de 19.2 cambia<Activity>pordisplay: none.
Este panel reúne casi todo el módulo. Las secciones viven en <Activity>, así que conservan el estado. El cambio de sección se anima según la dirección, con addTransitionType. Dentro de la plantilla, el avatar viaja a la ficha con share. Y si tienes activado el movimiento reducido, todo sigue funcionando sin animaciones.
import {
Activity,
Suspense,
ViewTransition,
addTransitionType,
startTransition,
use,
useState,
} from 'react'
const API = 'http://localhost:3000/api'
type Seccion = 'plantilla' | 'pedidos'
interface Empleado {
empId: number
nombre: string
apellidos: string
oficio: string
salario: number
}
interface Pedido {
pedidoId: number
fechaPedido: string
estado: string
}
const cache = new Map<string, Promise<unknown>>()
function pedir<T>(ruta: string) {
if (!cache.has(ruta)) {
cache.set(ruta, fetch(`${API}/${ruta}`).then((r) => r.json()))
}
return cache.get(ruta) as Promise<T>
}
function Avatar({ empleado }: { empleado: Empleado }) {
return (
<ViewTransition name={`empleado-${empleado.empId}`} share="morfosis" default="none">
<div className="miniatura">{empleado.nombre[0]}</div>
</ViewTransition>
)
}
function Plantilla() {
const { contenido } = use(pedir<{ contenido: Empleado[] }>('empleados?tamano=6'))
const [elegido, setElegido] = useState<Empleado | null>(null)
function abrir(empleado: Empleado | null) {
startTransition(() => setElegido(empleado))
}
if (elegido) {
return (
<div className="detalle">
<Avatar empleado={elegido} />
<div>
<h2>
{elegido.nombre} {elegido.apellidos}
</h2>
<p>
{elegido.oficio}, {elegido.salario} €
</p>
<button onClick={() => abrir(null)}>Volver</button>
</div>
</div>
)
}
return (
<div className="rejilla">
{contenido.map((e) => (
<button key={e.empId} onClick={() => abrir(e)}>
<Avatar empleado={e} />
{e.nombre}
</button>
))}
</div>
)
}
function Pedidos() {
const { contenido } = use(pedir<{ contenido: Pedido[] }>('pedidos?tamano=6&orden=-fechaPedido'))
return (
<ul className="simple">
{contenido.map((p) => (
<li key={p.pedidoId}>
Pedido {p.pedidoId}, {p.fechaPedido} <span className="discreto">{p.estado}</span>
</li>
))}
</ul>
)
}
function Zona({ visible, children }: { visible: boolean; children: React.ReactNode }) {
return (
<Activity mode={visible ? 'visible' : 'hidden'}>
<ViewTransition
default="none"
enter={{ avanza: 'entra-derecha', retrocede: 'entra-izquierda', default: 'none' }}
exit={{ avanza: 'sale-izquierda', retrocede: 'sale-derecha', default: 'none' }}
>
<div>
<Suspense fallback={<div className="esqueleto" />}>{children}</Suspense>
</div>
</ViewTransition>
</Activity>
)
}
export default function App() {
const [seccion, setSeccion] = useState<Seccion>('plantilla')
function ir(destino: Seccion) {
if (destino === seccion) return
startTransition(() => {
addTransitionType(destino === 'pedidos' ? 'avanza' : 'retrocede')
setSeccion(destino)
})
}
return (
<section className="tarjeta">
<h2>Panel de EMPRESA</h2>
<nav className="fila">
<button aria-pressed={seccion === 'plantilla'} onClick={() => ir('plantilla')}>
Plantilla
</button>
<button aria-pressed={seccion === 'pedidos'} onClick={() => ir('pedidos')}>
Pedidos
</button>
</nav>
<Zona visible={seccion === 'plantilla'}>
<Plantilla />
</Zona>
<Zona visible={seccion === 'pedidos'}>
<Pedidos />
</Zona>
</section>
)
}

Figura 13. Cambio de Plantilla a Pedidos congelado a media animación. La ficha abierta sigue ahí cuando vuelves.
Las claves del diseño:
- La
<Activity>envuelve al<ViewTransition>, y este va antes de cualquier nodo. Así se activanenteryexit. Suspensequeda dentro deZona, y la sección oculta se precarga: los pedidos ya están listos antes de entrar.pedirmemoriza las promesas por ruta, de modo que ocultar y mostrar no repite peticiones.- El nombre lleva el identificador:
empleado-102es único aunque haya una lista.
Comprobado en el navegador: la ficha abierta del empleado se conserva tras ir a Pedidos y volver, y la consola queda limpia.
18. Errores frecuentes y depuración
| Síntoma | Causa | Solución |
|---|---|---|
| No se anima nada | El setState no está en una transición | Envuélvelo en startTransition |
Se anima el cambio, pero no enter ni exit | Hay un nodo del DOM antes del límite | Pon <ViewTransition> primero |
There are two <ViewTransition name=%s>... | Nombre repetido | Añade el identificador al nombre |
| No viaja el elemento compartido | Un lado está fuera de la ventana, o hubo un fallback | Comprueba la posición, o precarga los datos |
| El botón Atrás no anima | La navegación llega por popstate | Es lo esperado con enrutadores que no usan la Navigation API |
| Toda la lista se funde | Hay un único límite | Un <ViewTransition key> por fila |
El evento onEnter no se dispara | default="none" lo ha apagado | Añade enter="auto" |
| Tras volver a una pestaña se repite la petición | El efecto se recrea al mostrar | Guarda los datos en una caché |
| Sigue sonando un vídeo oculto | display: none no lo detiene | Pausa en la limpieza de useLayoutEffect |
Una ref es null en una Activity oculta | Las refs se desconectan al ocultar | Mide cuando esté visible |
Render de servidor falla con browser() | Falta la frontera | Rodéalo con <Suspense> |
TypeError en innerHTML | Una cadena bajo Trusted Types | Crea el valor con una política |
| Un nodo se recrea en cada render | createHTML en cada render | Usa useMemo |
Para ver una animación con calma, abre las DevTools de Chromium, ve a Animations y reduce la velocidad. También puedes alargarlas un momento:
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 3s !important;
}
19. Ejercicios
Ejercicio 1: Catálogo con dos pestañas
Crea dos pestañas, Productos y Clientes, con un mismo componente Listado que recibe la ruta de la API y tiene su propio filtro. El filtro de cada pestaña debe conservarse al cambiar a la otra.
Ver solución
import { Activity, useEffect, useState } from 'react'
const API = 'http://localhost:3000/api'
interface Fila {
id: number
nombre: string
}
function Listado({ ruta, campoId }: { ruta: string; campoId: string }) {
const [filas, setFilas] = useState<Fila[]>([])
const [filtro, setFiltro] = useState('')
useEffect(() => {
fetch(`${API}/${ruta}?tamano=100`)
.then((r) => r.json())
.then((datos) =>
setFilas(
datos.contenido.map((d: Record<string, unknown>) => ({
id: d[campoId],
nombre: d.nombre,
})),
),
)
}, [ruta, campoId])
const visibles = filas.filter((f) => f.nombre.toLowerCase().includes(filtro.toLowerCase()))
return (
<>
<input
aria-label={`Filtrar ${ruta}`}
value={filtro}
onChange={(e) => setFiltro(e.target.value)}
/>
<ul className="simple">
{visibles.slice(0, 5).map((f) => (
<li key={f.id}>{f.nombre}</li>
))}
</ul>
</>
)
}
export default function App() {
const [activa, setActiva] = useState<'productos' | 'clientes'>('productos')
return (
<section className="tarjeta">
<div className="fila">
<button aria-pressed={activa === 'productos'} onClick={() => setActiva('productos')}>
Productos
</button>
<button aria-pressed={activa === 'clientes'} onClick={() => setActiva('clientes')}>
Clientes
</button>
</div>
<Activity mode={activa === 'productos' ? 'visible' : 'hidden'}>
<Listado ruta="productos" campoId="productoId" />
</Activity>
<Activity mode={activa === 'clientes' ? 'visible' : 'hidden'}>
<Listado ruta="clientes" campoId="clienteId" />
</Activity>
</section>
)
}
Cada Listado conserva su estado porque nunca se desmonta. Comprobado: tras escribir port, cambiar y volver, el filtro sigue ahí con 3 resultados.
Ejercicio 2: Sin peticiones repetidas
Muestra los departamentos en una Activity que se pueda ocultar y mostrar, sin que eso repita la petición.
Ver solución
import { Activity, Suspense, use, useState } from 'react'
const API = 'http://localhost:3000/api'
interface Departamento {
deptId: number
nombre: string
}
let promesa: Promise<{ contenido: Departamento[] }> | null = null
function cargar() {
promesa ??= fetch(`${API}/departamentos`).then((r) => r.json())
return promesa
}
function Departamentos() {
const { contenido } = use(cargar())
return (
<ul className="simple">
{contenido.map((d) => (
<li key={d.deptId}>{d.nombre}</li>
))}
</ul>
)
}
export default function App() {
const [ver, setVer] = useState(true)
return (
<section className="tarjeta">
<button onClick={() => setVer((v) => !v)}>{ver ? 'Ocultar' : 'Mostrar'}</button>
<Activity mode={ver ? 'visible' : 'hidden'}>
<Suspense fallback={<p>Cargando...</p>}>
<Departamentos />
</Suspense>
</Activity>
</section>
)
}
La petición sale de use con una promesa memorizada fuera del componente, y no de un efecto. Al mostrar de nuevo no se crea ninguna. Comprobado: cero peticiones al ocultar y mostrar. En una aplicación real, TanStack Query te da esto mismo con caducidad e invalidación.
Ejercicio 3: Panel de avisos animado
Crea un panel de avisos que entre y salga con las clases entra-abajo y sale-abajo, y que recuerde cuántos avisos has marcado como leídos cuando se cierra.
Ver solución
import { Activity, ViewTransition, startTransition, useState } from 'react'
function Avisos() {
const [leidos, setLeidos] = useState(0)
return (
<ViewTransition default="none" enter="entra-abajo" exit="sale-abajo">
<aside className="tarjeta">
<h2>Avisos</h2>
<p>Has marcado {leidos} como leídos.</p>
<button onClick={() => setLeidos((n) => n + 1)}>Marcar uno</button>
</aside>
</ViewTransition>
)
}
export default function App() {
const [abierto, setAbierto] = useState(false)
return (
<>
<div className="fila">
<button onClick={() => startTransition(() => setAbierto((a) => !a))}>
{abierto ? 'Cerrar avisos' : 'Abrir avisos'}
</button>
</div>
<Activity mode={abierto ? 'visible' : 'hidden'}>
<Avisos />
</Activity>
</>
)
}
La Activity conserva el contador, y la transición activa enter y exit. El límite va dentro de Avisos, como primer elemento. Comprobado: aparecer-abajo al abrir, salir-abajo al cerrar, y el contador sigue en 1.
Ejercicio 4: Asistente con dirección
Crea un asistente de tres pasos con botones 1, 2 y 3. Al avanzar, el contenido debe deslizar hacia un lado, y al retroceder, hacia el otro, aunque saltes del 3 al 1.
Ver solución
import { ViewTransition, addTransitionType, startTransition, useState } from 'react'
const pasos = ['Datos del cliente', 'Líneas del pedido', 'Confirmación']
export default function App() {
const [paso, setPaso] = useState(0)
function ir(destino: number) {
startTransition(() => {
addTransitionType(destino > paso ? 'avanza' : 'retrocede')
setPaso(destino)
})
}
return (
<section className="tarjeta">
<h2>Nuevo pedido</h2>
<div className="fila">
{pasos.map((nombre, i) => (
<button key={nombre} aria-pressed={i === paso} onClick={() => ir(i)}>
{i + 1}
</button>
))}
</div>
<ViewTransition
key={paso}
default="none"
enter={{ avanza: 'entra-derecha', retrocede: 'entra-izquierda', default: 'none' }}
exit={{ avanza: 'sale-izquierda', retrocede: 'sale-derecha', default: 'none' }}
>
<p>{pasos[paso]}</p>
</ViewTransition>
</section>
)
}
La dirección se decide comparando el destino con el paso actual. Comprobado: del 1 al 3 se usa avanza, y del 3 al 1, retrocede.
Ejercicio 5: Ficha de cliente con salida del esqueleto
Al elegir un cliente, muestra un esqueleto y, cuando lleguen los datos, haz que el esqueleto salga y la ficha entre, cada uno con su animación.
Ver solución
import { Suspense, ViewTransition, startTransition, use, useState } from 'react'
const API = 'http://localhost:3000/api'
interface Cliente {
clienteId: number
nombre: string
ciudad: string
}
const cache = new Map<number, Promise<Cliente>>()
function cargarCliente(id: number) {
if (!cache.has(id)) {
cache.set(id, fetch(`${API}/clientes/${id}`).then((r) => r.json()))
}
return cache.get(id)!
}
function Ficha({ id }: { id: number }) {
const cliente = use(cargarCliente(id))
return (
<ViewTransition default="none" enter="entra-abajo">
<article>
<h2>{cliente.nombre}</h2>
<p>{cliente.ciudad}</p>
</article>
</ViewTransition>
)
}
function Esqueleto() {
return (
<ViewTransition default="none" exit="sale-abajo">
<div className="esqueleto" />
</ViewTransition>
)
}
export default function App() {
const [id, setId] = useState<number | null>(null)
return (
<section className="tarjeta">
<div className="fila">
{[1001, 1002, 1003].map((n) => (
<button key={n} aria-pressed={n === id} onClick={() => setId(n)}>
{n}
</button>
))}
<button onClick={() => startTransition(() => setId(null))}>Limpiar</button>
</div>
{id !== null && (
<Suspense key={id} fallback={<Esqueleto />}>
<Ficha id={id} />
</Suspense>
)}
</section>
)
}
Es la segunda colocación del apartado 10: cada lado tiene su propio límite. key={id} en Suspense evita que un cliente reutilice la frontera del anterior. Comprobado: una sola transición con salir-abajo y aparecer-abajo.
Ejercicio 6: Zona horaria con valor opcional
Escribe un hook useZonaHoraria(porDefecto?). Con valor por defecto, debe renderizarse en el servidor. Sin él, debe detectar la zona solo en el navegador.
Ver solución
function useZonaHoraria(porDefecto?: string) {
if (porDefecto !== undefined) return porDefecto
use(browser('El servidor no conoce la zona horaria del usuario.'))
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
function ZonaHoraria({ porDefecto }: { porDefecto?: string }) {
const zona = useZonaHoraria(porDefecto)
return <p>Zona horaria: {zona}</p>
}
// En la página
<ZonaHoraria porDefecto="Europe/Madrid" />
<Suspense fallback={<p>Detectando tu zona...</p>}>
<ZonaHoraria />
</Suspense>
use es el único caso en que una llamada de este tipo puede ir tras un return condicional. Comprobado: el HTML del servidor contiene Europe/Madrid y, en la segunda, el fallback. Tras hidratar aparece la zona real.
Ejercicio 7: Vista previa con srcDoc
Bajo la cabecera de Trusted Types, crea una vista previa de un correo en un <iframe srcDoc>, con el texto escapado.
Ver solución
import { useMemo, useState } from 'react'
const politica = window.trustedTypes!.createPolicy('empresa', {
createHTML: (texto) =>
`<!doctype html><meta charset="utf-8"><pre>${texto.replaceAll('&', '&').replaceAll('<', '<')}</pre>`,
})
export default function App() {
const [texto, setTexto] = useState('Hola <b>Ana</b>')
const documento = useMemo(() => politica.createHTML(texto), [texto])
return (
<section className="tarjeta">
<h2>Vista previa del correo</h2>
<textarea aria-label="Correo" value={texto} onChange={(e) => setTexto(e.target.value)} />
<iframe title="Vista previa" sandbox="" srcDoc={documento as unknown as string} />
</section>
)
}
srcDoc está tipado como string en @types/react 19.3.0, por lo que hace falta la conversión. Comprobado en 19.3.1: dentro del iframe se ve Hola <b>Ana</b> como texto. Solo funciona desde 19.3. El atributo sandbox vacío añade otra capa de protección.
Ejercicio 8 (propuesto): Navegación con dirección
Sobre el ejemplo del apartado 12, usa useNavigate con addTransitionType dentro de startTransition para que los botones “Anterior” y “Siguiente” deslicen en sentidos contrarios.
Ejercicio 9 (propuesto): Detalle de pedido compartido
Haz que el número de pedido de una lista viaje al título de su detalle con share. Asegúrate de que el detalle se precarga, para que no intervenga ningún fallback.
Ejercicio 10 (propuesto): Cancelar en servidor
Tras leer el módulo 23, crea un Server Component con una función memorizada con cache, pasa cacheSignal() a fetch y registra cuándo se aborta.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Ocultar conservando | <Activity mode> 🆕 19.2 | Guarda estado y DOM, limpia los efectos |
| Precarga | <Activity mode="hidden"> | Solo con datos que activan Suspense |
| Cancelar en servidor | cacheSignal 🆕 19.2 | Solo en Server Components |
| Animar cambios | <ViewTransition> 🆕 19.3 | Solo con transiciones, Suspense o valores diferidos |
| Disparadores | enter, exit, update, share | React elige según cómo cambia el árbol |
| Colocación | Antes de cualquier nodo | Si no, no hay enter ni exit |
| Clases | ::view-transition-new(.clase) | Mejor que seleccionar por nombre |
| Causa | addTransitionType 🆕 19.3 | Elige la animación según el motivo |
| Elemento compartido | name único | Un lado sale y el otro entra a la vez |
| JavaScript | onEnter, instancia.new.animate | Devuelve una limpieza |
| Router | React Router 8 | Anima enlaces, no el botón Atrás |
| Solo navegador | use(browser()) 🆕 19.3 | Exige <Suspense> |
| Trusted Types | createPolicy 🔄 19.3 | innerHTML ya funcionaba, srcDoc es lo nuevo |
Identidad de __html | useMemo | Un objeto nuevo reasigna el DOM |
| Transiciones independientes | 🔄 19.3 | No incluye las Actions asíncronas |



