Tag: React

  • Cómo funcionan los hooks de React por dentro: Fiber y orden

    Cómo funcionan los hooks de React por dentro: Fiber y orden

    El PR venía con una nota: "optimización menor". Dentro, un useState movido dentro de un if, porque ese estado solo hacía falta si el usuario era admin. Tenía su lógica: para qué reservar memoria de algo que el 95% de la gente no usa.

    El lint saltó. El autor lo silenció con un // eslint-disable-next-line. Dos días después la app reventó: Rendered fewer hooks than expected.

    Entender cómo funcionan los hooks de React por dentro no es curiosidad académica: es lo que vuelve evidente esa regla. Porque "no llames hooks dentro de condicionales" la obedece todo el mundo sin saber por qué existe, y una regla que obedeces por fe acabas rompiéndola.

    No es un capricho del equipo de React. Es la consecuencia directa de dónde está guardado tu estado.

    En corto: tu estado no vive en la función del componente, vive en el nodo Fiber que React mantiene por cada instancia montada, dentro de una lista enlazada que cuelga del campo memoizedState. React no guarda el nombre de cada hook: recorre esa lista en orden, un nodo por llamada. Si el orden cambia entre renders, React entrega el estado equivocado a la llamada equivocada.


    ¿Qué es un hook de React por dentro?

    Un hook es una entrada de una lista enlazada asociada a un nodo Fiber: un objeto de cinco campos —memoizedState, baseState, baseQueue, queue y next— que React crea en el primer render y recupera por posición en todos los siguientes.

    No es una metáfora. Es el tipo literal, tal cual está en packages/react-reconciler/src/ReactFiberHooks.js:

    export type Hook = {
      memoizedState: any,
      baseState: any,
      baseQueue: Update<any, any> | null,
      queue: any,
      next: Hook | null,
    };
    

    Y en el mismo fichero, un comentario que ahorra media hora de lectura: "Hooks are stored as a linked list on the fiber's memoizedState field."

    Ojo con la trampa de nombres. En el Fiber, memoizedState apunta al primer hook de la lista. En cada Hook, memoizedState guarda el valor de ese hook concreto. Mismo nombre, dos niveles distintos.

    Un Fiber es el objeto que React mantiene por cada elemento del árbol —componente, nodo del DOM o fragmento— y que guarda su estado, el trabajo pendiente y su posición en el árbol; React 16 lo introdujo en 2017 para poder pausar, abandonar y reanudar el renderizado. Con su puntero alternate al Fiber del render anterior, es lo que sobrevive entre renders. Tu componente solo se ejecuta y muere.

    Por qué el orden de llamada de los hooks de React lo es todo

    Si React guarda los hooks en una lista y no guarda nombres, solo le queda una manera de saber cuál te toca: contar.

    La forma más rápida de que haga clic es escribir la versión de juguete. Ojo — es una simplificación pedagógica: React usa una lista enlazada por Fiber, no un array global. La identidad por posición, en cambio, funciona igual.

    // useState casero. React usa una lista enlazada por Fiber, no un array global:
    // esto es una simplificación para ver el mecanismo del orden.
    let hooks = [];
    let cursor = 0;
    
    function useState(initialValue) {
      const index = cursor; // esta llamada se queda con esta posición
      cursor++;             // la siguiente cogerá la siguiente
    
      if (!(index in hooks)) {
        hooks[index] = initialValue; // primer render: monta
      }
    
      const setState = (next) => {
        // igual que basicStateReducer en React: si es función, la aplica
        hooks[index] = typeof next === 'function' ? next(hooks[index]) : next;
        render();
      };
    
      return [hooks[index], setState];
    }
    
    function render() {
      cursor = 0;  // el cursor vuelve a cero al empezar cada render
      Component(); // tu componente: React lo vuelve a ejecutar
    }
    

    La línea que importa es cursor = 0: el array persiste, el cursor se reinicia. La identidad de cada hook es su posición en la secuencia de llamadas.

    Ahora mete un condicional en medio:

    function Perfil({ esAdmin }) {
      const [nombre, setNombre] = useState('Bezael');   // índice 0
    
      if (esAdmin) {
        const [permisos] = useState([]);                // índice 1 ... a veces
      }
    
      const [tema, setTema] = useState('oscuro');       // índice 1 o 2, según el día
    }
    

    Primer render con esAdmin: true: se montan tres hooks. nombre en 0, permisos en 1, tema en 2.

    El usuario pierde el rol. Segundo render con esAdmin: false: solo hay dos llamadas, así que tema lee el índice 1 — donde estaba permisos. Durante ese render tu string 'oscuro' es un array vacío, y al terminar el componente React ve que sobraba un hook en la lista y revienta con Rendered fewer hooks than expected: el error del PR de arriba.

    El caso caro es el que no altera el conteo — un if/else con un useState en cada rama. Mismo número de llamadas, ningún error, valor equivocado. React no puede echar en falta lo que no falta.

    Las comprobaciones que sí tiene viven en la propia lista. En updateWorkInProgressHook, si pides un hook que no existía antes:

    throw new Error('Rendered more hooks than during the previous render.');
    

    Y al terminar el render, si faltan hooks respecto a la lista previa, salta el otro: "Rendered fewer hooks than expected. This may be caused by an accidental early return statement." En desarrollo hay además un aviso que compara la secuencia hook a hook: "React has detected a change in the order of Hooks called by…".

    Los tres mensajes que vas a ver, y qué significa cada uno:

    Mensaje Cuándo salta Qué lo causó Dónde vive
    Rendered more hooks than during the previous render. Durante el render Este render pide un hook que el anterior no tenía: el if se abrió updateWorkInProgressHook
    Rendered fewer hooks than expected. This may be caused by an accidental early return statement. Al terminar el render Faltan llamadas respecto a la lista previa: un return temprano o un if que se cerró finishRenderingHooks
    React has detected a change in the order of Hooks called by... Solo en __DEV__ Mismo número de hooks, distinto orden: compara la secuencia hook a hook aviso de desarrollo
    (ninguno) Nunca El caso peligroso: el tipo de hook coincide y el valor se corrompe en silencio —

    Esa última fila es la que importa. Los tres errores son el caso amable.

    No fue un accidente que luego hubo que justificar: fue una decisión discutida en público. El RFC de Hooks lo abrió Sebastian Markbåge en octubre de 2018, y Dan Abramov dedicó un artículo entero a las alternativas descartadas, Why Do React Hooks Rely on Call Order? (13 de diciembre de 2018). Sobre identificar hooks por nombre en vez de por posición, escribió:

    "With this proposal, any time you add a new state variable inside a custom Hook, you risk breaking any components that use it (directly or transitively) because they might already use the same name for their own state variables."

    Y sobre por qué tampoco compensaba arreglarlo con más lint: "But if we have to lint anyway, what problem did we solve?".

    Ese es el trueque: React se traga una regla incómoda a cambio de que los custom hooks compongan sin colisiones de nombres.

    Mount y update son dos funciones distintas

    Segunda pieza: useState no es una función. Son dos.

    React no importa useState desde el reconciler. Lo lee de un dispatcher, un objeto que apunta a una implementación u otra según el momento. La línea vive en renderWithHooks, la función que envuelve la ejecución de tu componente:

    ReactSharedInternals.H =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
    

    mountState crea el nodo: reserva el hook, guarda el valor inicial en memoizedState y baseState, monta la cola y ata el dispatch. Ahí está, de paso, por qué el lazy initializer se ejecuta una sola vez: el typeof initialState === 'function' vive dentro de mountStateImpl, y a esa rama no vuelves nunca. updateState no crea nada: recupera el hook por posición y calcula el valor procesando su cola con basicStateReducer.

    El montaje son estas líneas:

    function mountWorkInProgressHook(): Hook {
      const hook: Hook = {
        memoizedState: null, baseState: null,
        baseQueue: null, queue: null, next: null,
      };
    
      if (workInProgressHook === null) {
        // This is the first hook in the list
        currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
      } else {
        // Append to the end of the list
        workInProgressHook = workInProgressHook.next = hook;
      }
      return workInProgressHook;
    }
    

    Hay más dispatchers. Uno es ContextOnlyDispatcher: el famoso "Invalid hook call" no es una comprobación mágica, es que el puntero apunta a un objeto cuyos métodos, salvo use y readContext, solo saben lanzar errores.

    Colas de actualización y comparación de dependencias

    Cuando llamas a setState no pasa casi nada: React crea un objeto Update, lo mete en una cola circular colgada de hook.queue.pending y programa trabajo. El valor nuevo se calcula durante el render.

    Pero hay un atajo que explica un comportamiento confuso. En dispatchSetStateInternal, si la cola está vacía React calcula el estado siguiente antes de renderizar y compara:

    const eagerState = lastRenderedReducer(currentState, action);
    update.hasEagerState = true;
    update.eagerState = eagerState;
    if (is(eagerState, currentState)) {
      // Fast path. We can bail out without scheduling React to re-render.
      enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
      return false;
    }
    

    Por eso setCount(count) con el mismo valor no siempre provoca un render: el atajo solo aplica si la cola estaba vacía. Si ya había algo pendiente, React renderiza igual. Y aun cuando el atajo entra, la documentación avisa de que React puede necesitar ejecutar tu componente una vez antes de saltarse a los hijos.

    La comparación de dependencias de useEffect, useMemo y useCallback es aún más simple. areHookInputsEqual recorre el array posición a posición:

    for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
      if (is(nextDeps[i], prevDeps[i])) {
        continue;
      }
      return false;
    }
    return true;
    

    Ese is viene de shared/objectIs, que es Object.is. Superficial, elemento a elemento, sin recursión. Un objeto literal nuevo en cada render nunca pasa el test, porque Object.is({}, {}) es false. Ahí está la causa de casi todos los "mi efecto se dispara en bucle": un objeto o un array creado en el cuerpo del componente y metido tal cual en el array de dependencias.

    Y ojo con la salida fácil: validar ese objeto tampoco lo estabiliza — cada parse devuelve una referencia nueva. Lo que estabiliza es depender de primitivas (user.id, no user), y para eso necesitas tener escrito el contrato de esos datos: es lo que trabajo en el curso de Zod.

    El stale closure: el bug que no es un bug

    Un stale closure es una función que sobrevive al render en el que nació y sigue leyendo las props y el estado de aquella ejecución concreta, no los actuales. Con el modelo mental montado, el clásico se explica solo:

    function Contador() {
      const [count, setCount] = useState(0);
    
      useEffect(() => {
        const id = setInterval(() => {
          console.log(count);  // siempre 0
          setCount(count + 1); // siempre 0 + 1
        }, 1000);
        return () => clearInterval(id);
      }, []); // el array vacío congela el render nº 1
    }
    

    La callback capturó el count del primer render. No es una referencia a "el estado": es una constante de aquella ejecución de la función. El Fiber avanza; esa closure no.

    No es una rareza, es un roce de diseño que la comunidad llevó al repositorio. En el issue "Design decision: why do we need the stale closure problem in the first place?", abierto por Sébastien Lorber en septiembre de 2019, el argumento era este:

    "Coupling the dependencies of the closure and the conditions to trigger effect re-execution does not make much sense to me."

    Tardó años, pero React acabó dándole parte de razón. Las tres salidas, de más vieja a más nueva:

    1. Updater funcional: setCount(c => c + 1). React aplica tu función sobre el estado real durante el render, no sobre la copia congelada.
    2. Ref: guardas el valor en un useRef y lees ref.current dentro del intervalo.
    3. useEffectEvent, estable desde React 19.2: saca del efecto la parte que lee lo último, sin que ese valor entre en las dependencias.

    useState, useRef y useMemo: la misma caja, distinto contrato

    Los tres guardan cosas en hook.memoizedState. Lo que cambia es qué guardan y quién avisa a React.

    Qué hay en hook.memoizedState Cuándo cambia ¿Provoca render? Límite / riesgo real
    useState El valor y una queue con pending, lastRenderedReducer y lastRenderedState Al procesar la cola durante el render Sí — dispatchSetState programa trabajo Lees un snapshot del render actual, no "el estado ahora". Origen de casi todos los stale closures
    useRef El objeto {current: initialValue}, creado una vez y devuelto siempre Cuando mutas .current, al instante No — React ni se entera Si la UI depende de ese valor, no se repinta. Leerlo en el render rompe la pureza
    useMemo La tupla [valor, deps] Si areHookInputsEqual da false No Es una pista, no una garantía: React puede descartar el cache
    useCallback La tupla [callback, deps] Igual que useMemo No Estabiliza la referencia, no el contenido: sigue capturando los valores de su render

    useRef y useState son la misma caja con distinto contrato frente al render. Y la última columna es la que más cuesta: memorizar no es gratis. mountMemo guarda un array por hook y updateMemo recorre las dependencias en cada render. Si el cálculo cuesta menos que comparar sus dependencias, useMemo te hace la app más lenta y más difícil de leer.

    Los custom hooks de React no tienen magia

    Un custom hook es una función que llama a hooks. Punto. No hay registro, no hay instancia, no hay contexto propio.

    function useUsuario(id) {
      const [datos, setDatos] = useState(null);   // ocupa la siguiente posición libre
      const [cargando, setCargando] = useState(true);
      useEffect(() => { /* ... */ }, [id]);
      return { datos, cargando };
    }
    

    Esos tres hooks se insertan en la lista del componente que llama, en el punto exacto de la secuencia donde estaba la llamada. El cursor no distingue entre "hooks míos" y "hooks del custom hook". Por eso componen sin colisiones: las llamadas a función forman un árbol, y un árbol se recorre en orden.

    Y por eso la regla sube hacia arriba: si metes un if dentro de un custom hook, rompes el orden de todos los componentes que lo usan aunque su código se vea impecable. Para la parte de tipos, lo desmonté aparte en cómo tipar props, hooks y contextos en TypeScript.

    Render y commit: dos fases, y solo una toca el DOM

    La fase de render ejecuta tu función, recorre la lista de hooks y calcula el árbol nuevo. Es interrumpible: React puede empezarla, abandonarla y rehacerla con otra prioridad. Por eso tu componente tiene que ser puro — si escribes en una variable externa durante el render, esa escritura puede ocurrir dos veces o ninguna. Strict Mode ejecuta tu componente dos veces en desarrollo —y monta, desmonta y vuelve a montar los efectos— a propósito para sacar a la luz ese tipo de bug. La fase de commit aplica los cambios al DOM y ejecuta los efectos, y esa no se interrumpe.

    El batching vive entre las dos. Desde React 18, con createRoot, React agrupa todas las actualizaciones antes de renderizar vengan de donde vengan. Dan Abramov lo dejó escrito en la discusión del Working Group de React 18:

    "Until React 18, we only batched updates during the React event handlers. Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default."

    Tres setState dentro de un fetch().then() daban tres renders en React 17. Hoy dan uno. Cómo se coloca ese trabajo en la cola del navegador está en cómo los microtasks afectan a la renderización.

    Contrástalo con el otro modelo mental del frontend actual: un signal guarda el valor en el propio objeto y notifica a quien lo lee; un hook lo guarda en el Fiber y obliga a reejecutar el componente entero. Comparé ambos en cómo funcionan los Signals en Angular 22 y React 19, y esa reactividad granular llevada a producción es la base del curso de Angular Moderno.

    Lo que este modelo mental NO te resuelve

    Es un detalle de implementación, y cambia. Nada de lo que has leído es API pública. Campos como baseQueue, lanes o revertLane llegaron con el modo concurrente y se han movido de sitio más de una vez. Sirve para razonar y depurar; no escribas código que dependa de ello.

    Saber los internals no arregla un useEffect mal planteado. Entender areHookInputsEqual te dice por qué tu efecto se dispara en bucle. No te dice que ese efecto no debería existir. La mayoría de los que reviso son estado derivado que debería calcularse en el render. El array de dependencias es el síntoma, no la enfermedad.

    El React Compiler cambia parte del cálculo. Desde la versión 1.0 estable, del 7 de octubre de 2025, el compilador inserta la memoización en tiempo de build y tu intuición sobre "cuándo compensa un useMemo" deja de aplicarse igual. Ojo con el entusiasmo: la guía oficial recomienda "leaving existing memoization in place (removing it can change compilation output)". El orden de llamada, en cambio, no lo toca — se apoya en él, porque necesita que cumplas las reglas para poder optimizar.

    Qué hacer con esto hoy

    Abre el componente más grande que tengas y cuenta sus hooks. Luego, hook a hook: ¿este valor necesita provocar un render, o me vale un useRef? ¿Este useMemo cuesta más que comparar sus dependencias? ¿Este efecto lee algo del pasado sin darse cuenta? Veinte minutos, y te va a quitar código.

    La próxima vez que alguien proponga meter un hook dentro de un if "porque tiene sentido", ya no tienes que apelar a la autoridad del lint. Le enseñas el cursor y se acabó la discusión.

    Publico desmontajes así cada semana en el canal de YouTube, y el material largo con proyectos completos vive en Dominicode Labs.

    Preguntas frecuentes

    ¿Por qué no puedo llamar a un hook dentro de un if?

    Porque React no identifica los hooks por su nombre, sino por su posición en la secuencia de llamadas. Los guarda en una lista enlazada colgada del campo memoizedState del Fiber y la recorre en orden en cada render.

    Si un condicional hace que una llamada aparezca en un render y no en el siguiente, las posiciones posteriores se desplazan y cada hook recibe el estado del de al lado. React lanza "Rendered more hooks than during the previous render" o "Rendered fewer hooks than expected" en parte de estos casos, pero no en todos: algunos te corrompen el valor en silencio.

    ¿Dónde guarda React el estado de useState exactamente?

    En el nodo Fiber del componente, no en la función. Cada Fiber tiene un campo memoizedState que apunta al primer hook de una lista enlazada, y cada hook es un objeto con los campos memoizedState, baseState, baseQueue, queue y next.

    El valor actual de tu useState vive en el memoizedState de su hook; las actualizaciones pendientes, en una cola circular dentro de queue.pending.

    ¿Qué es un stale closure en React y cómo lo evito?

    Es una función que sobrevive a su render y sigue viendo los valores de props y estado del momento en que se creó. El caso típico es un setInterval dentro de un useEffect con dependencias vacías: la callback capturó el estado del primer render y no lo ve cambiar nunca.

    Tienes tres salidas: la forma funcional de actualizar (setCount(c => c + 1)), un useRef cuyo .current mantienes al día, o useEffectEvent, estable desde React 19.2.

    ¿Cuál es la diferencia real entre useRef y useState?

    La misma caja con distinto contrato. useState guarda el valor y una cola de actualizaciones, y llamar a su setter programa un render. useRef guarda un objeto { current: valor } que React crea una sola vez y devuelve idéntico en todos los renders: mutar .current es instantáneo y React ni se entera.

    Usa useRef para lo que no debe repintar la pantalla y useState para lo que la UI tiene que reflejar.

    ¿Qué es React Fiber?

    Es la arquitectura interna del reconciliador de React desde la versión 16 (2017). Cada elemento del árbol —componente, nodo del DOM, fragmento— tiene su propio objeto Fiber que guarda su estado, el trabajo pendiente y sus punteros al resto del árbol.

    Su razón de ser es que el renderizado se pueda interrumpir: React puede empezar a construir un árbol, abandonarlo a medias y rehacerlo con otra prioridad. El campo memoizedState de cada Fiber es donde cuelga la lista enlazada de hooks de ese componente.

    ¿Por qué mi useEffect se ejecuta en bucle infinito?

    Casi siempre porque una de sus dependencias es un objeto, un array o una función que creas en el cuerpo del componente. React compara las dependencias con areHookInputsEqual, que recorre el array posición a posición usando Object.is: superficial, sin recursión.

    Object.is({}, {}) es false, así que un literal nuevo en cada render nunca pasa el test, el efecto vuelve a ejecutarse, cambia el estado, provoca otro render y vuelta a empezar. Saca el valor fuera del componente, memorízalo, o pregúntate si ese efecto debería existir.

    ¿Qué significa el error "Invalid hook call"?

    Que llamaste a un hook cuando el dispatcher activo era ContextOnlyDispatcher: un objeto cuyos métodos, salvo use y readContext, solo saben lanzar ese error. No hay detección mágica, hay un puntero apuntando al sitio equivocado.

    Pasa en tres situaciones: llamar al hook fuera de un componente o custom hook, tener dos copias de React en el árbol de dependencias, o una desincronización entre react y react-dom.

    ¿Puedo confiar en estos internals para escribir código?

    No. Nada de esto es API pública y el reconciler se reescribe cada pocas versiones: para escribir código, la fuente sigue siendo la documentación oficial y el plugin de ESLint.

    El valor de conocer los internals es otro: depurar más rápido, entender los mensajes de error y dejar de obedecer las reglas por fe.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • React 19.3: la release para borrar código, no para escribirlo

    React 19.3: la release para borrar código, no para escribirlo

    Hace un mes revisé el repo de un cliente. Una app de e-commerce, React, tres años de vida, gente competente detrás.

    Busqué isMounted en el proyecto. Diecinueve resultados. Busqué wrapperRef. Once. Y en el package.json, una librería de animación de 40 KB que solo se usaba para hacer un fade entre dos pantallas.

    Ninguna de las tres es un error. Son workarounds: código que existe porque React no daba la primitiva y alguien lo resolvió con lo que había.

    React 19.3 salió estable en npm el 9 de septiembre de 2026 y es, sobre todo, una release para borrar ese tipo de código. El problema de un workaround es que se queda para siempre, y cada dev nuevo del equipo asume que así es como se hace.

    No trae un paradigma nuevo. Trae dos APIs que salen de experimental —View Transitions y Fragment Refs—, una nueva, browser(), y un ajuste en Server Components. Cada una sustituye un apaño concreto que llevas años arrastrando. Vamos una por una, con lo que se borra en cada caso.

    En resumen: React 19.3 (react@19.3.0 y react-dom@19.3.0, publicados en npm el 9 de septiembre de 2026) estabiliza View Transitions y Fragment Refs, estrena browser() en react-dom, permite renderizar un Context directamente desde un Server Component y mejora el soporte de Trusted Types. No documenta ningún breaking change.

    Cambio Estado en 19.3 Se importa de Qué código elimina
    <ViewTransition> Estable (era experimental) react Librería de animación para transiciones de página y enter/exit de listas
    addTransitionType() Nueva react Estado de dirección pasado por props hasta el componente animado
    Fragment con ref Estable (era experimental) react El <div> wrapper que solo existía para colgar un ref, y el cloneElement
    browser() Nueva react-dom El trío useState + useEffect + flag mounted
    Context en Server Components Nuevo — El componente 'use client' que solo renderizaba el Provider
    Trusted Types Nuevo react-dom Sanitizado manual de TrustedHTML / TrustedScript

    View Transitions en React 19.3: borra la librería de animación

    <ViewTransition> ya no es experimental. Se importa de react y envuelve el trozo del árbol que quieres animar.

    import { ViewTransition, useState, startTransition } from 'react';
    
    export default function Component() {
      const [showItem, setShowItem] = useState(false);
      return (
        <>
          <button onClick={() => { startTransition(() => { setShowItem(prev => !prev); }); }}>
            {showItem ? '➖' : '➕'}
          </button>
          {showItem && (
            <ViewTransition>
              <Video video={videos[0]} />
            </ViewTransition>
          )}
        </>
      );
    }
    

    No hay animate, no hay variants, no hay AnimatePresence. Envuelves y ya. Por debajo React se apoya en la View Transition API del navegador.

    Hay cuatro formas de activarlo, según lo que pase en el árbol:

    • enter — se añade un ViewTransition al árbol.
    • exit — se elimina.
    • update — cambian sus hijos, sea contenido o style.
    • share — un ViewTransition con nombre desaparece en un sitio y aparece en otro.

    Cada tipo tiene su prop del mismo nombre —enter, exit, update, share— y su event prop: onEnter, onExit, onUpdate, onShare. Y existe además default, que fija la animación de los tipos que no declares: es lo que te permite apagarlos todos de golpe con default="none".

    Y ahora la regla que te va a costar media hora si no la lees: un setState normal no dispara la animación. Solo se activa dentro de una Transition: startTransition, useTransition, useDeferredValue o una navegación de Suspense.

    No es un detalle de implementación, es la decisión de diseño central: React no anima por si acaso, anima cuando tú marcas el cambio como transición. Si tu <ViewTransition> no hace nada, mira el setState antes que el CSS.

    Segundo detalle: la direccionalidad. El caso clásico —un carrusel que debe animar distinto según vayas hacia delante o hacia atrás— se resuelve con addTransitionType, otra función nueva de react:

    import { addTransitionType, startTransition } from 'react';
    
    function nextSlide() {
      startTransition(() => {
        addTransitionType('next');
        setCurrentSlide(c => c + 1);
      });
    }
    

    Etiquetas la transición y luego la consumes en CSS con :active-view-transition-type(next), o mapeas tipo → animación directamente en el componente:

    <ViewTransition
      enter={{ 'next': 'from-right', 'previous': 'from-left' }}
      exit={{ 'next': 'to-left', 'previous': 'to-right' }}
    >
      <Page />
    </ViewTransition>
    

    Ese mapa { tipo: animación } sustituye al estado de dirección que antes mantenías, pasabas por props hasta el componente animado y traducías a variants. Ahora vive donde ocurre la navegación.

    Y cuando dentro hay un Suspense, la doc recomienda animar solo el cambio de fallback a contenido y no todo lo que se mueva por debajo:

    <ViewTransition update="auto" default="none">
      <Suspense fallback={<Fallback />}>
        <Component />
      </Suspense>
    </ViewTransition>
    

    Qué borras: las transiciones de página y los enter/exit de listas y modales. Ojo, no digo que desinstales tu librería de animación mañana: los gestos, los drags, los springs físicos y las animaciones interrumpibles siguen siendo territorio de Framer Motion o GSAP. Pero si la instalaste para hacer un fade entre rutas —que es la mayoría de los casos que veo en revisiones— ya no la necesitas.


    Fragment Refs en React 19.3: un ref sin el <div> wrapper

    Fragment Refs permiten pasar un ref a un <Fragment> y recibir un FragmentInstance que opera sobre los hijos de primer nivel sin añadir ningún nodo al DOM. Son estables desde React 19.3.

    Es el cambio más pequeño de la release y probablemente el que más veces al mes te va a ahorrar un mal rato.

    Fragment ahora acepta ref. Te devuelve un FragmentInstance que opera sobre los hijos de primer nivel, sin meter un nodo extra en el DOM.

    Los métodos disponibles son estos:

    Categoría Métodos
    Eventos addEventListener, removeEventListener, dispatchEvent
    Foco focus() (en profundidad, depth-first), focusLast(), blur()
    Observers observeUsing(observer), unobserveUsing(observer)
    Layout y DOM getClientRects(), getRootNode(), compareDocumentPosition(otherNode), scrollIntoView(options)

    Un componente InView que antes exigía envolver los hijos en un div —con el consiguiente estropicio si el padre era un grid o un flex— ahora se escribe así:

    import { Fragment, useRef, useLayoutEffect } from 'react';
    
    export default function InView({ onChange, children }) {
      const fragmentRef = useRef(null);
    
      useLayoutEffect(() => {
        const visibleElements = new Set();
        const observer = new IntersectionObserver((entries) => {
          entries.forEach(e => {
            if (e.isIntersecting) visibleElements.add(e.target);
            else visibleElements.delete(e.target);
          });
          onChange(visibleElements.size > 0);
        });
        const fragmentInstance = fragmentRef.current;
        fragmentInstance.observeUsing(observer);
        return () => { fragmentInstance.unobserveUsing(observer); };
      }, [onChange]);
    
      return <Fragment ref={fragmentRef}>{children}</Fragment>;
    }
    

    Fíjate en observeUsing: le pasas el observer y él se encarga de suscribir a cada hijo. No iteras children, no clonas elementos, no pides refs a los hijos.

    Para accesibilidad, mover el foco al primer elemento enfocable de un grupo se queda en dos líneas:

    import { Fragment, useRef, useEffect } from 'react';
    
    function Component() {
      const fragmentRef = useRef(null);
    
      useEffect(() => {
        const fragmentInstance = fragmentRef.current;
        fragmentInstance.focus();
      }, []);
    
      return (
        <Fragment ref={fragmentRef}>
          {posts.map(post => (
            <Heading key={post.id}>{post.title}</Heading>
          ))}
        </Fragment>
      );
    }
    

    Qué borras: los wrappers de layout que no pintan nada y el cloneElement con ref que usabas para llegar a los hijos. Es el mismo tipo de deuda que genera el props drilling en React: estructura que existe para transportar algo, no para representar nada.


    use(browser()) en React 19.3: borra el flag mounted

    browser() es una función nueva de react-dom que, consumida con use(browser()), suspende en el servidor y no suspende en el cliente: marca un componente como client-only sin useState ni useEffect.

    Es el cambio más discreto de la release y el que más código muerto elimina en una app con SSR.

    El patrón que todos hemos escrito mil veces: un componente necesita window, Intl, localStorage o cualquier cosa que solo existe en el navegador, así que montas el ritual de useState(false) + useEffect(() => setMounted(true), []) + if (!mounted) return null.

    React 19.3 mete browser() en react-dom. Se consume con use() y su comportamiento cabe en una línea: suspende en el servidor y no suspende en el cliente.

    import { Suspense, use } from 'react';
    import { browser } from 'react-dom';
    
    function TimeZone() {
      use(browser());
      const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
      return <p>{timeZone}</p>;
    }
    
    export default function App() {
      return (
        <>
          <p>Your current time zone is:</p>
          <Suspense fallback="Loading...">
            <TimeZone />
          </Suspense>
        </>
      );
    }
    

    Tres líneas de estado sustituidas por una. Y el fallback deja de ser null para pasar a ser un Suspense de verdad, que es lo que debería haber sido siempre.

    Además se puede llamar condicionalmente, cosa que no es habitual en las APIs de React:

    function TimeZone({ defaultValue }) {
      if (defaultValue) return <p>{defaultValue}</p>;
      use(browser());
      const localTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
      return <p>{localTimeZone}</p>;
    }
    

    Y dentro de tus propios hooks, que es donde se pone interesante:

    function useBrowserQuery(query, options) {
      if (options.initialData === undefined) use(browser());
      return useQuery(query, options);
    }
    

    Ahí acabas de mover una decisión de renderizado —"esto solo puede resolverse en cliente"— del componente a la capa de datos. El componente ya no sabe nada del entorno.

    Requisito: necesitas un Suspense por encima. Sin él no funciona. Si todavía tratas Suspense como un spinner y no como una herramienta de orquestación, aquí lo desarrollo: fetching paralelo en Next.js con Suspense y Promise.all.


    Context en Server Components: borra el Provider intermedio

    Desde React 19.3, un Server Component puede importar un Context definido en un módulo 'use client' y renderizarlo directamente, sin un componente Provider intermedio.

    Si trabajas con RSC conoces el peaje. Para pasar datos del servidor al árbol de cliente había que crear un componente 'use client' cuya única razón de existir era renderizar el Provider:

    // user-context.js  ('use client')
    export const UserContext = createContext(null);
    export function UserProvider({ currentUser, children }) {
      return <UserContext value={currentUser}>{children}</UserContext>;
    }
    
    // server-component.js
    import { UserProvider } from './user-context';
    export async function Layout({ children }) {
      const currentUser = await getCurrentUser();
      return <UserProvider currentUser={currentUser}>{children}</UserProvider>;
    }
    

    Ahora el Server Component importa el Context directamente de su módulo 'use client' y lo renderiza:

    // user-context.js  ('use client')
    export const UserContext = createContext(null);
    
    // server-component.js
    import { UserContext } from './user-context';
    export async function Layout({ children }) {
      const currentUser = await getCurrentUser();
      return <UserContext value={currentUser}>{children}</UserContext>;
    }
    

    Un fichero menos y, sobre todo, una capa de indirección menos. Si estás montando esto en producción, escribí sobre las decisiones reales de React Server Components que hay detrás de esa frontera server/client: este cambio elimina uno de los puntos de fricción que mencionaba allí.


    Otros cambios de React 19.3: Trusted Types, rendimiento y renombrados

    Tres cosas más que no dan para post pero que conviene saber.

    Trusted Types. React ya no coerciona a string los valores TrustedHTML, TrustedScript y TrustedScriptURL: los pasa tal cual para que el navegador valide la política. Si sirves con Content-Security-Policy: require-trusted-types-for 'script', esto cierra una vía de XSS basado en DOM que antes tenías que tapar tú.

    Rendimiento. Dos cambios, sin cifras publicadas y sin que yo me las invente: las Transitions se renderizan de forma independiente en vez de enredarse en un único render, así que una Transition lenta ya no bloquea a otras que no tienen nada que ver; y las actualizaciones que vienen de eventos de resize se agrupan hasta el siguiente frame. Si tu app tiene layout reactivo al viewport, lo notarás sin tocar nada.

    Renombrados y fixes. useActionState pasa a hablar de "action state" en vez de "form state". Y hay arreglos que probablemente expliquen algún bug que archivaste como "cosas raras": useDeferredValue quedándose con el valor viejo, useSyncExternalStore perdiendo mutaciones, useEffectEvent roto dentro de forwardRef y memo, y la propagación de context en los fallbacks de Suspense.


    ¿Merece la pena actualizar a React 19.3 hoy?

    La nota de release oficial de React 19.3 no documenta ningún breaking change. La instalación es esta:

    npm install react@19.3 react-dom@19.3
    

    Aviso para quien llegue desde los sandboxes de la doc: ahí verás versiones 19.3.0-canary-*. Esas son del entorno de la documentación, no la instrucción de instalación. La estable es 19.3.0.

    Mi recomendación: actualiza y no migres nada todavía. Primero mide cuánto código muerto tienes. Es un ejercicio de veinte minutos y te da la lista de la compra.

    Yo lo hago con un agente: le pido a Claude Code que barra el repo buscando los tres patrones —flags de mounted, wrappers que solo existen para un ref y animaciones de ruta hechas con librería— y que me devuelva un inventario con ubicación y coste, no un PR. Que el agente haga el inventario y la decisión la tomes tú es justo lo que enseño en el curso Construye con IA: de la idea al producto con Claude Code.

    Y si además sigues el ecosistema Angular, merece la pena comparar cómo resuelve cada framework lo mismo. Lo desarrollé en el post sobre Signals en Angular 22 frente a React 19. React 19.3 refuerza esa dirección: el modelo de reactividad no se toca, lo que mejora es qué puedes expresar sin escribir infraestructura.


    Por dónde empezar a migrar a React 19.3

    Abre tu proyecto y busca mounted. Solo eso.

    Cada resultado es un componente que tarda un render extra en aparecer, que probablemente hace flash en producción y que existe porque React no tenía forma de decir "esto es client-only". Ahora la tiene. Ese es el mejor punto de entrada a React 19.3 porque el cambio es local, no rompe nada y se ve en el primer render.

    Cuando termines con esos, ve a por los <div> wrapper. Y deja las transiciones de página para el final, que son las más divertidas y las que más tiempo te van a comer.

    En Dominicode Labs estamos probando estas APIs sobre proyectos reales y compartiendo lo que funciona y lo que no. Y si prefieres verlo en vídeo, lo iré contando en el canal de Dominicode a medida que lo lleve a producción.


    Preguntas frecuentes sobre React 19.3

    ¿React 19.3 rompe algo si actualizo desde 19.2?

    La nota de release de React 19.3 no documenta ningún breaking change. La actualización es npm install react@19.3 react-dom@19.3 y las APIs nuevas son aditivas: ViewTransition y addTransitionType no estaban en la API estable —vivían en los builds experimentales—, browser() es nueva, y que Fragment acepte ref no cambia el comportamiento de los Fragment que ya tienes. Aun así, actualiza en una rama y pasa tu suite de tests: la release incluye arreglos en useDeferredValue, useSyncExternalStore y useEffectEvent, y si tu código dependía sin saberlo del comportamiento defectuoso, ahí es donde lo vas a notar.

    ¿Por qué mi ViewTransition no anima nada?

    Casi siempre por lo mismo: el cambio de estado no está dentro de una Transition. <ViewTransition> solo se activa con startTransition, useTransition, useDeferredValue o una navegación de Suspense. Un setState normal actualiza el DOM sin animar. Antes de tocar el CSS, comprueba que el setState que provoca el cambio está envuelto en una Transition.

    ¿Puedo usar View Transitions de React 19.3 con Next.js o React Router?

    Sí, porque <ViewTransition> es un componente de react y no depende del router. La condición es que el cambio de estado ocurra dentro de una Transition, y las navegaciones de los routers modernos ya lo hacen. Lo que no obtienes automáticamente es la direccionalidad: para animar distinto hacia delante y hacia atrás tienes que llamar tú a addTransitionType('next') dentro del startTransition que dispara la navegación.

    ¿Fragment Refs sustituyen a los refs normales?

    No. Un ref normal apunta a un nodo del DOM y eso sigue siendo lo correcto cuando ese nodo existe. Fragment Refs resuelven el caso en el que necesitas operar sobre un conjunto de hijos y no hay un elemento común que los envuelva, o lo hay solo porque lo metiste tú para colgar el ref. El FragmentInstance que recibes trabaja sobre los hijos de primer nivel: registra eventos, mueve el foco con focus() y focusLast(), conecta un IntersectionObserver o un ResizeObserver con observeUsing(), mide con getClientRects() y hace scroll con scrollIntoView().

    ¿use(browser()) elimina todos los useEffect de mi app?

    No, solo un patrón concreto: el de marcar un componente como client-only. browser() se importa de react-dom, suspende en el servidor y no suspende en el cliente, así que sustituye al trío useState + useEffect + flag mounted. Necesita un Suspense por encima para funcionar. Los efectos que sincronizan con sistemas externos —suscripciones, listeners, integraciones con librerías no-React— siguen siendo useEffect.

    ¿Necesito un framework con Server Components para aprovechar React 19.3?

    Para nada. View Transitions, Fragment Refs y use(browser()) funcionan en cualquier aplicación React; browser() cobra especial sentido si haces SSR, pero no exige RSC. Lo que sí requiere una configuración con Server Components es la mejora de Context, que permite a un Server Component importar un Context definido en un módulo 'use client' y renderizarlo directamente, sin el componente Provider intermedio.

    ¿Cómo instalo React 19.3 y por qué veo versiones canary en la documentación?

    Se instala con npm install react@19.3 react-dom@19.3. Las versiones 19.3.0-canary-* que aparecen en los sandboxes interactivos de react.dev pertenecen al entorno de la propia documentación y no son la instrucción de instalación. En npm, react@19.3.0 y react-dom@19.3.0 son estables desde el 9 de septiembre de 2026.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Fetching de Datos en Paralelo en Next.js con Suspense

    Fetching de Datos en Paralelo en Next.js con Suspense

    Hace unos meses audité el dashboard de un SaaS construido con el App Router de Next.js. El usuario iniciaba sesión y la pantalla tardaba 4,2 segundos en mostrar el primer píxel interactivo.

    El equipo pensaba que el cuello de botella estaba en los índices de PostgreSQL o en la memoria de la máquina en Vercel.

    Abrí el archivo page.tsx del dashboard. Había cuatro llamadas con await consecutivas:

    // ❌ El clásico waterfall en Server Components
    const user = await getUser();
    const stats = await getStats(user.id);
    const notifications = await getNotifications(user.id);
    const recommendations = await getExternalRecommendations();
    

    Cada petición esperaba a que la anterior terminara: 400ms + 1.200ms + 600ms + 2.000ms. Un waterfall secuencial de 4,2 segundos. Y peor aún: si el microservicio de recomendaciones caía con un error 504, toda la página devolvía un error 500 al cliente.

    Next.js te ofrece React Server Components por defecto, pero si no estructuras el fetching de datos en paralelo en Next.js, conviertes tu servidor en una fila india de bloqueos innecesarios.

    Aquí te muestro la arquitectura en tres capas para paralelizar llamadas, tolerar caídas de microservicios y hacer streaming instantáneo hacia el navegador.


    1. El antipatrón del Waterfall secuencial

    Cuando colocas llamadas asíncronas consecutivas en el cuerpo de una función de Server Component, la ejecución en Node.js se detiene en cada línea.

    TIEMPO (ms)  0ms       400ms                1600ms          2200ms                    4200ms
                 ├─────────┼────────────────────┼───────────────┼─────────────────────────┤
    Llamada 1:   [getUser]
    Llamada 2:             [getStats]
    Llamada 3:                                  [getNotifs]
    Llamada 4:                                                  [getRecommendations]
                                                                                          ▲
                                                                               Primer render (4.2s)
    

    Las peticiones no arrancan a la vez; arrancan en cascada. Salvo que una llamada requiera obligatoriamente el resultado de la anterior para construir su consulta, ejecutar esto en serie es desperdiciar los hilos de red del servidor.


    2. Nivel 1: fetching de datos en paralelo con Promise.all

    Si necesitas varios bloques de datos indispensables para renderizar la vista y ninguno depende de otro, la primera optimización es disparar todas las promesas al mismo tiempo con Promise.all:

    // app/dashboard/page.tsx
    interface DashboardData {
      user: User;
      stats: UserStats;
      notifications: Notification[];
    }
    
    export default async function DashboardPage() {
      // Inicia todas las promesas en paralelo
      const [user, stats, notifications] = await Promise.all([
        getUser(),
        getStats(),
        getNotifications(),
      ]);
    
      return (
        <main className="p-6">
          <UserProfile user={user} />
          <StatsOverview stats={stats} />
          <NotificationList items={notifications} />
        </main>
      );
    }
    

    La ganancia:

    El tiempo total de espera ya no es la suma de todas las llamadas, sino el tiempo de la más lenta. Si la más lenta tarda 1.200ms, la página resuelve en 1.200ms en lugar de 2.200ms.

    TIEMPO (ms)  0ms             1200ms
                 ├───────────────┤
    getUser:     [==== 400ms ====]
    getStats:    [======== 1200ms =======]
    getNotifs:   [====== 600ms ======]
                                 ▲
                      Resuelve en paralelo (1.2s)
    

    La trampa de Promise.all:

    Promise.all tiene comportamiento de rechazo rápido (fail-fast). Si dos promesas resuelven con éxito pero una falla, toda la llamada lanza una excepción. Úsalo exclusivamente para datos que son 100% obligatorios para la vista.


    3. Nivel 2: Promise.allSettled para tolerancia a fallos

    ¿Qué ocurre cuando una página incluye datos secundarios, como recomendaciones de productos, widgets del clima o analíticas de terceros?

    No tiene sentido romper el perfil del usuario porque una API externa esté caída. Para peticiones secundarias o no bloqueantes, la solución nativa es Promise.allSettled:

    // app/dashboard/page.tsx
    export default async function DashboardPage() {
      const [profileResult, recommendationsResult] = await Promise.allSettled([
        getUserProfile(),
        getThirdPartyRecommendations(),
      ]);
    
      // Si el perfil falla, cortamos porque es crítico
      if (profileResult.status === "rejected") {
        throw new Error("No se pudo cargar el perfil del usuario.");
      }
    
      const profile = profileResult.value;
    
      // Si las recomendaciones fallan, degradamos elegantemente sin romper la UI
      const recommendations =
        recommendationsResult.status === "fulfilled"
          ? recommendationsResult.value
          : [];
    
      return (
        <section>
          <UserProfile user={profile} />
          {recommendations.length > 0 ? (
            <RecommendationCarousel items={recommendations} />
          ) : (
            <p className="text-sm text-gray-500">Recomendaciones no disponibles hoy.</p>
          )}
        </section>
      );
    }
    

    Promise.allSettled garantiza que Node.js esperará a que todas las promesas finalicen, devolviendo un objeto con { status: 'fulfilled', value } o { status: 'rejected', reason }. Tu interfaz resiste caídas parciales sin tirar el servidor.


    4. Nivel 3: React <Suspense> y Streaming para pulverizar el TTFB

    Incluso con Promise.all, si un componente tarda 2,5 segundos, el usuario mirará una pantalla en blanco durante 2,5 segundos antes de ver el primer byte HTML.

    Con React Suspense y Streaming en Next.js, desacoplas la carga de la página del componente más lento.

    La regla de oro: mueve el await dentro del componente que realmente consume los datos.

    // app/dashboard/page.tsx
    import { Suspense } from "react";
    import { UserHeader } from "./components/UserHeader";
    import { HeavyAnalyticsWidget } from "./components/HeavyAnalyticsWidget";
    import { SkeletonWidget } from "./components/SkeletonWidget";
    
    export default function DashboardPage() {
      return (
        <div className="space-y-6">
          {/* Carga inmediata (rápido) */}
          <Suspense fallback={<p>Cargando cabecera...</p>}>
            <UserHeader />
          </Suspense>
    
          {/* Widget pesado: no bloquea el resto de la página */}
          <Suspense fallback={<SkeletonWidget />}>
            <HeavyAnalyticsWidget />
          </Suspense>
        </div>
      );
    }
    
    // app/dashboard/components/HeavyAnalyticsWidget.tsx
    // Este Server Component hace su propio fetching asíncrono
    export async function HeavyAnalyticsWidget() {
      const data = await getHeavyMetrics(); // Tarda 2.5s
      return <MetricsChart data={data} />;
    }
    

    Qué experimenta el usuario:

    1. En 80 milisegundos, el servidor envía la estructura HTML principal, el menú, la cabecera y el skeleton del gráfico.
    2. La página es interactiva de inmediato.
    3. A los 2,5 segundos, Next.js envía por streaming el fragmento HTML del gráfico y React lo reemplaza en el DOM sin recargar la página.

    Si este patrón te suena a fricción con Server Components mal migrados, cubrimos los fallos más comunes en Errores comunes al migrar a React Server Components en producción.

    Esta mentalidad de arquitectura modular y control de estados asíncronos es exactamente lo que desarrollamos en el curso Construye con IA: De la Idea al Producto con Claude y Specs, donde conectamos interfaces modernas en Next.js con servicios backend de alta concurrencia.


    Comparativa: ¿Cuándo usar cada técnica?

    Escenario Técnica recomendada Beneficio clave
    Múltiples datos obligatorios para el layout principal Promise.all Máxima velocidad paralela (tiempo = el de la llamada más lenta)
    Widgets externos o servicios propensos a timeouts Promise.allSettled Resiliencia y degradación elegante
    Componentes lentos o dashboards con múltiples secciones React <Suspense> + Streaming TTFB mínimo y UX instantánea
    Datos con dependencias en cadena (A depende de B) await secuencial estricto Integridad en la cadena de datos

    Arquitectura de fetching de datos en paralelo en producción

    El patrón profesional más sólido en aplicaciones Next.js de gran escala combina los tres enfoques:

    1. El layout principal y la vista estructural no bloquean con await globales innecesarios.
    2. Cada bloque funcional vive en su propio Server Component envuelto en <Suspense>.
    3. Dentro de cada bloque funcional, si se requieren múltiples recursos, se utiliza Promise.all (si son obligatorios) o Promise.allSettled (si toleran fallo).

    Si además necesitas exprimir memoria y builds en producción, ya cubrimos ese terreno en Optimización extrema de rendimiento y consumo de memoria en Next.js 16.

    Para profundizar en optimizaciones avanzadas de memoria, caching y patrones de Server Components en producción, en Dominicode Labs revisamos arquitecturas reales y analizamos benchmarks de rendimiento semana a semana.


    Preguntas frecuentes

    ¿Promise.all en Next.js Server Components ejecuta en el cliente o en el servidor?

    En Server Components (archivos sin 'use client'), Promise.all se ejecuta íntegramente en el entorno de Node.js o Edge del servidor antes de generar o transmitir el HTML al cliente.

    ¿Qué diferencia hay entre Promise.all y Promise.allSettled?

    Promise.all rechaza inmediatamente si cualquiera de las promesas falla (comportamiento todo o nada), mientras que Promise.allSettled espera a que todas concluyan y entrega el estado individual (fulfilled o rejected) de cada una.

    ¿Suspense sustituye por completo a Promise.all?

    No. Se complementan. <Suspense> gestiona el streaming y la interfaz de carga progresiva entre diferentes componentes, mientras que Promise.all gestiona la concurrencia de datos dentro de un mismo componente.

    ¿El uso de Suspense afecta al SEO en Google?

    No. Los rastreadores web modernos de Google esperan la resolución del stream de Server Components y reciben el HTML final renderizado con el contenido completo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Next.js 16.3: el 90% menos de memoria es real, pero no es tuyo

    Next.js 16.3: el 90% menos de memoria es real, pero no es tuyo

    El portátil empezó a hacer ese ruido. El del ventilador que ya no refrigera, solo pide ayuda.

    Dos horas con next dev abierto, saltando entre rutas de un checkout. 14 GB de RAM. Un servidor de desarrollo comiéndose catorce gigas.

    Maté el proceso. Lo levanté. A los diez minutos iba otra vez por seis y subiendo.

    Ese ha sido el peaje de trabajar en local durante años: cuanto más rato llevas, peor va todo, y el arreglo es reiniciar.

    Next.js 16.3 llegó estable el 3 de agosto de 2026 apuntando justo ahí. El titular que circula desde entonces: «90% menos memoria y builds 5,5× más rápidos».

    Las dos cifras son ciertas. Y las dos son el mejor caso medido por Vercel en sus propias aplicaciones.

    La release es buena de verdad y no necesita ese titular. Lo mejor de 16.3 no es el número grande: es cuánto llega activado por defecto, sin que toques una línea de tu código.

    Qué trae Next.js 16.3 de un vistazo

    Novedad ¿Por defecto? Qué aporta
    Memory eviction en Turbopack Sí Hasta 90% menos RAM en next dev (mejor caso medido)
    Caché en disco en next build Sí Compilación de Turbopack 1,4× a 5,5× más rápida
    SSR con streams nativos de Node Sí Hasta 22% más peticiones bajo carga
    Agrupado de prefetches pequeños Sí Menos peticiones por navegación
    Type checking con TypeScript 7 Solo subir la dependencia Compilador nativo en next build
    Docs versionadas en AGENTS.md Sí El agente lee la doc de tu versión instalada
    React Compiler en Rust No — experimental 34% más rápido en frío, 46% en caliente
    Cache Components y Partial Prefetching No — opt-in Navegación instantánea

    De dónde sale el 90% menos de memoria en Turbopack

    El memory eviction es la capacidad de Turbopack de liberar de la RAM las partes del grafo de módulos que no está usando y recuperarlas del disco cuando vuelven a hacer falta. Eso es lo nuevo de 16.3.

    Funciona junto con la caché en disco para desarrollo, que llegó en 16.1. Por eso las dos van de la mano: sin caché en disco, evictar sería volver a compilar desde cero.

    Estas son las mediciones oficiales, tomadas después de compilar 50 rutas:

    Aplicación Antes Con 16.3 Reducción
    vercel.com (dashboard) 21,5 GB 2 GB ~90%
    nextjs.org 4.600 MB 840 MB ~82%

    Fíjate en la aplicación grande: partir de 21,5 GB solo es posible en una máquina de 32 o 64 GB. La mayoría de proyectos no llegan ahí ni queriendo.

    Y esto es lo que dice el propio equipo de Turbopack en su post de la release, y que casi nunca sobrevive al resumen:

    No existe un único porcentaje de reducción aplicable a todas las aplicaciones. Los resultados individuales dependen del tamaño del grafo de rutas, de cuánto se haya recorrido durante la sesión de desarrollo y de cuánto tiempo llevara la sesión ejecutándose.

    Traducido a tu día a día: si tu proyecto tiene 12 rutas y reinicias el servidor cada media hora, no vas a ver un 90%. Vas a ver una mejora modesta, porque nunca llegaste a acumular la basura que el eviction limpia.

    El 90% lo notan los monorepos con cientos de rutas y las sesiones de ocho horas sin reiniciar. Que, siendo justos, es exactamente donde dolía.

    Si algo se rompe raro, tienes la salida:

    // next.config.ts
    import type { NextConfig } from 'next'
    
    const nextConfig: NextConfig = {
      experimental: {
        // el valor por defecto es 'full'
        turbopackMemoryEviction: false,
      },
    }
    
    export default nextConfig
    

    Por qué el next build 5,5× más rápido es el mejor de tres casos medidos

    El 5,5× no es la mejora media de la release: es la mejor de las tres aplicaciones que Vercel midió.

    La caché en disco que aceleraba next dev desde 16.1 ahora funciona también en next build. Y en la versión estable viene activada por defecto — ojo si leíste el post del preview de junio, donde todavía era el flag opt-in turbopackFileSystemCacheForBuild. Cambió al estabilizar.

    Estos son los tiempos de compilación de Turbopack dentro de next build, de frío a con caché:

    Aplicación Build en frío Con caché Mejora
    nextjs.org 21 s 9,2 s ~2,3×
    vercel.com/home 66 s 46 s ~1,4×
    vercel.com/geist 30 s 5,5 s ~5,5×

    El 5,5× existe. Es la última fila. También existe el 1,4×, que es la aplicación más parecida a un proyecto real con integraciones, y es la que menos se cita.

    El rango honesto de esta release es 1,4× a 5,5×, y dónde caigas tú depende de cuánto de tu grafo cambie entre build y build. Si tocas un archivo compartido que arrastra media aplicación, la caché te sirve de poco. Si tocas una página hoja, te sirve muchísimo.

    Dos detalles antes de que alguien te enseñe la tabla en una reunión.

    La cifra es el tiempo de compilación de Turbopack, no el next build completo: sigues teniendo type checking, generación de páginas estáticas y el resto del pipeline por delante.

    Y la caché no existe en el primer build. Necesitas una ejecución previa. En local eso pasa solo. En CI, no.

    En CI la caché no aparece por arte de magia

    Cada job arranca en un contenedor limpio. Si no persistes nada, siempre estás midiendo el build en frío y esta mejora no la ves jamás.

    Lo que hay que persistir es .next/cache, que es donde Turbopack escribe su caché de build. Esta es la configuración que da la documentación oficial de CI build caching para GitHub Actions:

    - uses: actions/cache@v4
      with:
        path: |
          ~/.npm
          ${{ github.workspace }}/.next/cache
        # Genera caché nueva cuando cambian dependencias o fuentes
        key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
        # Si cambió el código pero no las dependencias, reconstruye desde una caché previa
        restore-keys: |
          ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
    

    restore-keys es la línea que casi todo el mundo se deja. Sin ella solo recuperas la caché cuando la clave coincide exacta, y como la clave incluye el hash de tus fuentes, eso no pasa nunca en un commit nuevo: siempre medirías builds en frío.

    Y no caches .next entero. Ahí vive también la salida del build, que next build regenera igualmente, así que solo consigues subir y bajar cientos de megas por job y comerte antes la cuota de caché del repositorio — que expulsa entradas viejas cuando se llena. Acabas perdiendo justo la caché que querías conservar.

    El React Compiler en Rust es experimental, y la letra pequeña importa

    Han reescrito el React Compiler en Rust y lo han integrado en Turbopack. Va detrás de dos flags:

    // next.config.ts
    const nextConfig: NextConfig = {
      reactCompiler: true,
      experimental: {
        turbopackRustReactCompiler: true,
      },
    }
    

    Contra la aplicación de v0 midieron un 34% más rápido en frío y un 46% en caliente. Dos precisiones antes de que lo actives.

    La métrica es el tiempo desde que lanzas next dev hasta que la página está lista, no «tiempos de build de página». Es arranque de desarrollo, no producción.

    Y el post oficial avisa: esas ganancias asumen que has abandonado Babel por completo. Si sigues ejecutando Babel para otras transformaciones, el compilador en Rust ayuda, pero la ganancia es menor.

    Si todavía arrastras un .babelrc para i18n o para decoradores, el 46% no es tuyo. La ganancia real no es Rust: es salir de Babel. Rust solo hace que salir de Babel merezca todavía más la pena.

    Es experimental. Yo lo activaría en una rama, mediría y decidiría. No en el pipeline del viernes.

    Lo que mejora en Next.js 16.3 sin que hagas absolutamente nada

    Tres mejoras no piden ni un flag ni una línea de código. Es la parte que más me gusta de la release, y la menos vistosa.

    Type checking con TypeScript 7. next build ya soporta el compilador nativo y solo tienes que subir la dependencia con pnpm add -D typescript@^7. Sobre por qué esto cambia tanto los tiempos escribí en TypeScript 7 y el compilador en Go.

    SSR más rápido. Han sustituido las web streams por streams nativos de Node en la capa de render del App Router. Resultado: hasta un 22% más de peticiones bajo carga, cero cambios en tu código, menos overhead por request en la capa que ya usas si trabajas con React Server Components en producción.

    Documentación versionada para agentes de IA. next dev escribe y mantiene un bloque en tu AGENTS.md que apunta a los docs del node_modules del propio proyecto. Tu agente deja de inventarse APIs de la versión equivocada porque lee la documentación de la versión que tienes instalada. Vercel ha retirado sus Skills anteriores: para esto ya no hacen falta.

    Y esto importa más de lo que parece. Cuando un agente te genera código Next.js que no compila, muchas veces no es el modelo: es que aprendió de tutoriales de tres versiones atrás. Anclar el contexto a la versión instalada es la misma disciplina que aplico en Construye con IA: el agente no necesita más inteligencia, necesita mejor contexto.

    Y una cuarta que también llega sola: los prefetch por debajo de cierto tamaño se agrupan, así que tu app hace menos peticiones sin que cambies nada.

    Lo demás que trae 16.3 son APIs nuevas que sí tienes que escribir tú: catchError para error boundaries que ya no interfieren con notFound ni redirect, import.meta.glob al estilo Vite (solo con Turbopack) y root params con import { lang } from 'next/root-params' para dejar de pasar el idioma por props. Reutilizar assets estáticos inmutables entre despliegues también es opt-in, no automático.

    La otra mitad de la release

    Todo lo de arriba llega solo con actualizar. La otra mitad de 16.3 no: hay que activarla a mano y decidir dónde.

    Hablo de Cache Components, Partial Prefetching, el Navigation Inspector y el helper instant() de Playwright. Se activa con dos flags:

    // next.config.ts
    const nextConfig: NextConfig = {
      cacheComponents: true,
      partialPrefetching: true,
    }
    

    Lo cubrí cuando la versión estaba en preview, en cómo conseguir navegaciones instantáneas con Cache Components. Ese es el siguiente paso.

    Qué hacer hoy

    Actualizar es un minor sin cambios de API:

    npm install next@latest
    

    Pero antes, haz lo que casi nadie hace: mide.

    Anota cuánta RAM consume tu next dev tras una hora de trabajo normal y cuánto tarda tu next build en CI. Dos números en una nota. Actualiza, trabaja una semana y vuelve a mirarlos.

    El debate no es si el 90% es real — lo es, en el dashboard de Vercel. El debate es cuánto es en tu proyecto. Y esa cifra no la tiene el blog oficial: la tienes tú, y solo si la mediste antes.

    Escribir lo que esperas antes de ejecutarlo y contrastarlo después es lo mismo que defiendo en el libro de Spec-Driven Development: sirve igual para una feature que para actualizar un framework. Y si quieres contrastar números con gente que está actualizando esta misma semana, esas conversaciones están en Dominicode Labs.

    Preguntas frecuentes

    ¿De verdad Next.js 16.3 usa un 90% menos de memoria?

    En el mejor caso medido, sí: el dashboard de vercel.com pasó de 21,5 GB a 2 GB tras compilar 50 rutas. En nextjs.org la reducción fue del 82%, de 4.600 MB a 840 MB. El equipo de Turbopack advierte de que no existe un porcentaje único aplicable a todas las aplicaciones, porque depende del tamaño del grafo de rutas, de cuánto se recorra durante la sesión y de cuánto tiempo lleve el servidor levantado. Proyectos pequeños con reinicios frecuentes verán mejoras mucho menores.

    ¿Tengo que cambiar código para aprovechar Next.js 16.3?

    No para la mayor parte. El memory eviction, la caché en disco en next build, los streams nativos de Node en SSR y el agrupado de prefetches vienen activados por defecto. TypeScript 7 requiere únicamente subir la dependencia. Solo son opt-in el React Compiler en Rust, que además es experimental, y las features de navegación instantánea como Cache Components.

    ¿Actualizar a Next.js 16.3 rompe algo?

    Es una versión minor y no trae cambios de API que obliguen a tocar tu código. Lo que sí cambia es el comportamiento en tiempo de ejecución, porque el memory eviction y la caché de build llegan activados por defecto. Si tras actualizar ves recompilaciones inesperadas o rarezas en el HMR, el escape es experimental.turbopackMemoryEviction: false, y luego reportarlo.

    ¿El build 5,5× más rápido aplica también al primer build?

    No. La cifra compara un build en frío contra uno posterior que reutiliza la caché en disco, así que necesitas una ejecución previa. El rango real medido por Vercel va de 1,4× en vercel.com/home a 5,5× en vercel.com/geist, y corresponde al tiempo de compilación de Turbopack, no al next build completo con type checking y generación de páginas.

    ¿Cómo aprovecho la caché de build en CI?

    Persistiendo .next/cache entre ejecuciones, que es donde Turbopack guarda su caché de build. En GitHub Actions se hace con actions/cache apuntando a ~/.npm y a ${{ github.workspace }}/.next/cache, con una key que incluya el hash del lockfile y de tus fuentes, y restore-keys con el prefijo del lockfile para poder reconstruir desde una caché previa. No caches .next entero: el resto del directorio lo regenera next build de todas formas y solo te come cuota.

    ¿Y si mi proyecto sigue compilando con webpack?

    Las dos mejoras del titular son de Turbopack: el memory eviction y la caché en disco para next build no existen fuera de él. Lo que sí obtienes sin depender del bundler son los streams nativos de Node en SSR y el type checking con TypeScript 7, porque ocurren en la capa de render y en el paso de tipos, no en el empaquetado. import.meta.glob tampoco funciona fuera de Turbopack.

    ¿Merece la pena activar el React Compiler en Rust?

    Depende de si sigues usando Babel. Medido contra la aplicación de v0 es un 34% más rápido en frío y un 46% en caliente —la métrica es el tiempo desde next dev hasta tener la página lista, no un build de producción—, pero el post oficial aclara que esas ganancias asumen haber abandonado Babel por completo; si lo mantienes para otras transformaciones, la ganancia es menor. Sigue siendo experimental: actívalo en una rama y mide antes de meterlo en tu pipeline principal.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Tests unitarios lentos: el número de Vitest que casi nadie mira

    Tests unitarios lentos: el número de Vitest que casi nadie mira

    Nuestro job de tests en CI tardaba doce minutos clavados. Setecientos siete ficheros, cuatro mil cuatrocientos tests. Nadie lo cuestionaba: una suite grande tarda, y punto.

    Hoy lo hemos dejado en seis minutos y quince segundos. Sin borrar un solo test, sin runners más caros, sin paralelizar nada. Solo cambiando qué entorno arranca cada fichero.

    Y lo interesante no es el 48 % que nos ahorramos. Es que llevábamos meses con tests unitarios lentos mirando el número equivocado.

    El número equivocado es el total. El total te dice que tienes un problema, pero no te dice dónde se va el tiempo. Y sin el dónde, optimizar es tirar cosas a la pared: cambias el entorno, subes los threads, añades runners, y a veces sale bien y a veces sale peor y nunca sabes por qué.

    Vitest te da el dónde al final de cada ejecución. Lo tienes impreso en tu terminal ahora mismo.


    Resumen rápido

    • El total del Duration te dice que tienes tests unitarios lentos, no dónde se va el tiempo. El desglose sí.
    • environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. Compáralo contra tests, nunca contra Duration.
    • En nuestra suite: 803,6 s de environment contra 156 s de tests. Cinco veces más en montar el escenario que en ejecutarlo.
    • La causa: environment: 'jsdom' global para 707 ficheros, de los que solo 208 tocan el DOM.
    • El fix: dos proyectos de Vitest, la extensión decide el entorno. .test.ts a node, .test.tsx a DOM. 12m → 6m 15s en CI.
    • Shardear no arregla esto: algo más de la mitad del tiempo es coste fijo de arranque, y cada shard lo vuelve a pagar entero.

    Tests unitarios lentos: el desglose que Vitest imprime y nadie lee

    Debajo del Duration hay un paréntesis con seis campos: transform, setup, collect, tests, environment y prepare.

    En nuestro caso, los dos que importan salían así:

    Duration  106.31s (transform …, setup …, collect …, tests 156.00s, environment 803.60s, prepare …)
    

    Recorto los campos que no vienen al caso. Fíjate en la contradicción aparente: el run entero duró 106 segundos, pero dice que gastó 803 en environment.

    No es un bug. environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. La suite corre en dieciséis workers y cada uno monta su propio entorno por fichero. La cifra que ves es la suma de todos ellos, así que puede ser más de siete veces mayor que el reloj de pared.

    La regla de lectura del desglose de Vitest es comparar acumulado contra acumulado: environment contra tests, nunca contra Duration. Duration es wall-clock; los otros dos son tiempos sumados entre ficheros y workers.

    Y ahí el número deja de ser abstracto: 803,6 segundos montando el escenario contra 156 ejecutando los tests. Cinco veces más en preparar que en actuar.

    Cuando environment multiplica varias veces a tests, el problema no son los tests lentos: es el arranque del entorno.


    707 ficheros arrancando un navegador, 208 usándolo

    El origen estaba en una línea del config: environment: 'jsdom', global, para los 707 ficheros.

    Conté los que tocaban el DOM de verdad. Eran 208. Los 499 restantes —parsers, cálculo de precios, mapeo de rutas de API, validadores— montaban un navegador falso entero para no usarlo jamás.

    Ese es el gasto que estábamos pagando cinco veces sobre el trabajo real. No era un problema de rendimiento del emulador. Era que la mitad larga de la suite no necesitaba emulador ninguno.

    La solución fue partir la suite en dos proyectos de Vitest con una regla que cabe en una frase: la extensión decide el entorno.

    El config, con Vitest 4.1.10 (julio de 2026). La clave test.projects sustituyó a workspace en Vitest 3.2, así que en versiones anteriores esto no aplica:

    // vitest.config.ts
    import { defineConfig } from 'vitest/config'
    import react from '@vitejs/plugin-react'
    
    export default defineConfig({
      test: {
        projects: [
          {
            // Lógica pura: node pelado. Sin plugins, sin setup, sin DOM.
            test: {
              name: 'unit',
              environment: 'node',
              include: ['src/**/*.test.ts'],
            },
          },
          {
            // Componentes: DOM emulado + testing-library.
            plugins: [react()],
            test: {
              name: 'dom',
              environment: 'jsdom',
              include: ['src/**/*.test.tsx'],
              setupFiles: ['./src/test/setup-dom.ts'],
            },
          },
        ],
      },
    })
    

    Elegir la extensión como criterio no es cosmético. Con globs por carpeta o por sufijo (*.spec.ts y *.component.spec.ts) te toca mantener un exclude, porque el primer patrón se traga los ficheros del segundo y esos tests se ejecutan dos veces, una de ellas en el entorno equivocado y fallando por un motivo que parece un bug de tu código.

    .test.ts y .test.tsx no se solapan nunca. Cero exclude, cero ambigüedad, y una regla que un compañero nuevo entiende sin preguntar: si tu test importa JSX, es .tsx y tiene DOM.

    Si trabajas en Angular la idea es idéntica desde que Vitest es el runner por defecto. Cambian los globs, no el razonamiento.

    Después dimos el segundo paso: cambiar una palabra en el proyecto dom, de jsdom a happy-dom. En un A/B aislado sobre esos 208 ficheros, 48,9 s → 34,9 s. Unos catorce segundos. Útil, pero un orden de magnitud por debajo de lo que dio separar los entornos.

    La comparativa completa entre los dos emuladores, con benchmark y las APIs que le faltan a cada uno, la tengo aparte en el post sobre happy-dom o jsdom.

    El resultado de las dos cosas juntas:

    Ámbito Antes Después Δ
    Job Test en CI 12m 00s 6m 15s −48 %
    Suite local (16 cores) 106,3 s 46,0 s −57 %
    environment (acumulado entre ficheros) 803,6 s 137 s −83 %
    Ficheros / tests 707 / 4.400 707 / 4.400 sin cambios

    Mismos tests. Mismas aserciones. Misma cobertura.


    El efecto secundario: dos tests que llevaban meses mintiendo

    Al cambiar el entorno, dos tests empezaron a fallar.

    Y tenían razón.

    El patrón era este, y lo he visto en todos los proyectos en los que he entrado:

    vi.spyOn(global, 'fetch')
      .mockResolvedValueOnce(ok(productos))
      .mockResolvedValueOnce(ok(stock))
    

    Parece un mock. No lo es.

    vi.spyOn(global, 'fetch') sin implementación envuelve la función original y sigue llamándola. Lo único que intercepta son las respuestas que has encolado con mockResolvedValueOnce. Y esa cola se agota: la primera llamada recibe productos, la segunda stock, y la tercera sale a la red de verdad.

    Nuestro componente hacía tres llamadas.

    Llevaba meses pidiendo datos a localhost:3000 desde el runner de CI. jsdom se lo tragaba en silencio y el test seguía en verde. Con happy-dom la petición real quedó a la vista, y ahí aparecieron los 401.

    El arreglo son cuatro líneas, y es la clase de cosa que debería estar en el setup de cualquier suite:

    // setup-dom.ts
    import { beforeEach, vi } from 'vitest'
    
    beforeEach(() => {
      vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear'))
    })
    

    Un default que revienta. Si un test necesita una respuesta, la encola encima; si se le olvida una llamada, el test falla con un mensaje que dice exactamente qué pasó, en vez de irse a internet a buscar suerte.

    Añade también restoreMocks: true en el config: mockRejectedValue en un beforeEach no vacía la cola de ...Once que haya dejado el test anterior, y esa cola sobrante es una fuga entre tests igual de silenciosa que la que acabas de tapar.

    Yo esto ya no lo discuto: un test que llega a la red no es un test unitario, es una apuesta. Es lento, es flaky, y depende del firewall del runner. Diseñar los mocks para que el hueco falle ruidosamente en vez de degradar en silencio es la mitad del trabajo de testear bien, y es la parte que más tiempo dedico a explicar en el curso de Testing en Angular.

    Nadie planea encontrar estos bugs. Aparecen cuando tocas los cimientos.


    ¿Merece la pena shardear los tests unitarios? Los números dijeron que no

    Nos dio un 27 % a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que en unitarios la mayor parte del tiempo es arranque compartido, y repartirlo no lo divide: lo multiplica. Así llegamos ahí.

    Semanas antes habíamos partido la suite de E2E en shards concurrentes y el wall-clock se había desplomado. Fue de esas victorias que te dejan con ganas de repetir.

    Así que la pregunta era obvia: si funcionó con E2E, ¿por qué no con los unitarios?

    Los números decían que sí. De los 375 segundos del job, solo 31 eran setup —checkout, pnpm install, build de las librerías internas—, un 8 %. Con un overhead fijo tan bajo, repartir en tres debería habernos dejado en torno a los dos minutos y medio. Una mejora del orden del 60 %.

    Abrimos el PR, lo lanzamos, y esto es lo que salió:

    Job Tiempo
    Test (1) 4m 19s
    Test (2) 4m 32s ← wall-clock
    Test (3) 3m 57s
    Test (otros paquetes) 1m 38s

    375 s → 272 s. Un 27 %, a cambio de cuatro runners en vez de uno.

    Volvimos al desglose, que es lo que había que haber hecho antes de escribir el PR:

    Ámbito Ficheros Tiempo de tests
    Job completo 707 333 s
    Un shard 236 226 s

    Léelo despacio. Un tercio de los ficheros tarda el 68 % de lo que tardan todos.

    Si ajustas una recta T(n) = F + n·v con esos dos puntos, sale un coste fijo F de unos 172 segundos y una pendiente de 0,23 segundos por fichero. Traducido: de los 333 segundos, algo más de la mitad es peaje que pagas antes de ejecutar un solo test. Solo unos 160 dependen de cuántos ficheros tengas.

    Y aquí toca ser honesto con el método: dos puntos y dos incógnitas significa que la recta pasa por ambos por construcción. Es aritmética, no un perfilado. Te da el orden de magnitud del reparto entre lo fijo y lo variable, que es justo lo que necesitas para decidir, pero no lo cites como si fuera una medida.

    Ese coste fijo es transform más importación del grafo de dependencias, en frío, sin caché de Vite en el runner. Y cada shard lo paga entero, otra vez, desde cero.

    Con 3 shards pagas ese peaje tres veces. Con 6, seis. No hace falta el modelo para verlo: el shard más rápido de los tres, con 236 ficheros en vez de 707, todavía tardó 3m 57s. Por muchos runners que enchufes, el suelo se queda en unos cuatro minutos.

    La lección de E2E no transfería, y visto desde aquí es evidente. En Playwright cada test es trabajo independiente de navegador: repartir divide de verdad. En unitarios, la mayor parte del tiempo es arranque compartido, y repartir trabajo compartido no lo divide, lo multiplica.

    El sharding no era la palanca. La palanca era eliminar los ~172 segundos de coste fijo que cada shard vuelve a pagar entero.


    El PR sigue abierto, y creo que así está bien

    El PR #36 no está mergeado ni cerrado. Un 27 % por 4x runners es un trade flojo, y las tres opciones siguen sobre la mesa:

    • Cerrarlo. Los cien segundos no compensan cuadruplicar el consumo de CI ni la complejidad de un job matricial.
    • Bajarlo a 2 shards. Menos ganancia, la mitad de coste, y sospecho que el punto donde la curva todavía compensa.
    • Aparcarlo y atacar el coste fijo. Cachear node_modules/.vite entre runs, si esa caché existe en tu setup, porque hoy se reconstruye en frío cada vez. Si funciona, mejora los tres escenarios a la vez, incluido el de un solo runner.

    La tercera es la que tiene mejor pinta, y precisamente por eso no quiero decidirla con la misma prisa con la que abrimos el PR. Primero medir el arranque en caliente, después decidir.


    Qué hacer hoy si tienes tests unitarios lentos

    1. Lanza tus tests y mira el paréntesis del final. Solo eso.
    2. Compara environment con tests. Los dos son sumas acumuladas entre ficheros, así que la división tiene sentido. Si environment es el doble de tests, ya sabes dónde está tu problema, y no es donde llevas semanas buscándolo.
    3. Cuenta cuántos ficheros importan JSX o tocan document de verdad. En nuestro caso eran 208 de 707. En el tuyo probablemente sea una proporción parecida, porque un catálogo, un carrito o un dashboard tienen mucha más lógica que pintura.
    4. Separa por extensión. .test.ts a node, .test.tsx a DOM. Veinte minutos de trabajo, cero riesgo, ningún test tocado.

    La tesis de todo esto no es "usa happy-dom" ni "shardea tus tests". Es que medir el coste correcto —no el tiempo total, sino en qué se va— convierte una optimización a ciegas en un cambio de config de veinte líneas. El mismo desglose que nos quitó seis minutos nos evitó después tirar cuatro runners a un problema que no era de paralelismo.

    Y que de vez en cuando, al levantar los cimientos, encuentras un test que llevaba meses saliendo a internet sin que nadie se enterara.

    Si quieres ver este tipo de decisiones tomadas sobre proyectos reales, con los runs y los números delante en vez de con opiniones, es lo que hacemos en Dominicode Labs.


    Preguntas frecuentes sobre tests unitarios lentos

    ¿Por qué mis tests unitarios son lentos si cada test tarda milisegundos?

    Casi siempre porque el tiempo no se va en ejecutar los tests, sino en preparar el entorno de cada fichero. En nuestra suite, el desglose de Vitest daba 803,6 segundos acumulados en environment frente a 156 en tests: cinco veces más en montar el escenario que en actuar. Mientras esa proporción esté desequilibrada, optimizar aserciones o subir el número de threads no te va a dar nada: estarías acelerando la parte pequeña.

    ¿Qué significa environment en el resumen de Vitest?

    Es el tiempo dedicado a instanciar el entorno de test (jsdom, happy-dom o node) para cada fichero, sumado entre todos los workers. Es tiempo acumulado entre ficheros y workers, no wall-clock, y por eso puede ser mucho mayor que el Duration total: nosotros teníamos 803,6 segundos de environment en un run de 106,3 segundos corriendo sobre dieciséis workers. La comparación que tiene sentido es environment contra tests, porque ambas cifras están acumuladas de la misma forma.

    ¿Cómo separo los tests que necesitan DOM de los que no en Vitest?

    Con test.projects en vitest.config.ts: un proyecto con environment: 'node' para la lógica pura y otro con DOM emulado, plugin del framework y setupFiles para los tests de componente. Lo que mejor nos ha funcionado es decidir por extensión, .test.ts contra .test.tsx, porque son globs que no se solapan y no necesitas exclude. Si separas por carpeta o por sufijo compuesto, un mismo fichero puede caer en los dos proyectos y ejecutarse dos veces, una de ellas en el entorno equivocado.

    ¿Merece la pena shardear los tests unitarios en CI?

    Depende de qué proporción de tu tiempo sea coste fijo de arranque, y hay que medirlo antes de abrir el PR. En nuestro caso, tres shards dieron un 27 % de mejora a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que cada shard vuelve a pagar entero el transform y la importación del grafo de dependencias, así que ese coste no se reparte, se multiplica. Con E2E la historia es distinta porque cada test es trabajo independiente de navegador y repartir sí divide.

    ¿Por qué vi.spyOn(global, 'fetch') no mockea mis llamadas?

    Porque spyOn sin implementación envuelve la función original y sigue llamándola. Solo intercepta las respuestas que hayas encolado con mockResolvedValueOnce, y esa cola se agota: en cuanto tu código hace una llamada más de las que encolaste, esa petición sale a la red de verdad. El arreglo es poner siempre un default que falle, con vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear')) en el setup, y encolar las respuestas concretas encima en cada test.

    ¿Esto aplica igual en Angular?

    Sí, y desde Angular 21 y 22 aún más, porque Vitest pasó a ser el runner por defecto y el entorno DOM dejó de ser una decisión implícita del builder de Karma. Cambian los globs, que serán .spec.ts con algún criterio propio para distinguir tests de componente de tests de servicio, pero el diagnóstico es idéntico: mira el desglose de environment, cuenta cuántos specs necesitan document y manda el resto a node.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Visor de PDF en Next.js con Apryse WebViewer: guía real

    Visor de PDF en Next.js con Apryse WebViewer: guía real

    Un cliente me escribió con lo que parecía un encargo de dos semanas.

    «Necesitamos que el usuario abra su contrato dentro de la plataforma, lo anote, tache los datos sensibles y lo firme. Sin descargar nada, sin salir de la app.»

    Dos semanas. Claro.

    Lo que estaba pidiendo era un visor de PDF en Next.js con capa de anotaciones persistentes, redacción de verdad —no un rectángulo negro pintado encima, sino borrar el texto del documento— y firma. Es decir: un producto entero metido dentro de una ruta de la aplicación.

    Lo he visto intentar a mano tres veces. Las tres acabaron igual: seis meses después seguían peleando con fuentes embebidas y zoom en móvil.


    Resumen: visor de PDF en Next.js en 5 puntos

    • Un visor de PDF en Next.js es un componente de cliente que renderiza documentos dentro de tu aplicación, sin descargarlos ni delegar en el visor nativo del navegador. Requiere dos cosas que no son obvias: cargar la librería solo en el navegador y servir sus assets estáticos desde public/.
    • Construir a mano anotaciones, redacción y firma sobre PDF es un pozo sin fondo: el formato tiene 30 años de casos borde.
    • Apryse WebViewer (@pdftron/webviewer) te da ese flujo completo en el navegador, sin backend de por medio.
    • Es un SDK comercial con trial gratuito. Los paquetes de entrada arrancan en $1.500 según su web de precios —consultado en julio de 2026—, y el precio final es a medida.
    • Si solo necesitas mostrar un PDF, no lo uses. pdf.js o react-pdf te resuelven eso gratis.

    Por qué "solo un visor de PDF" nunca es solo un visor

    Un PDF no es una imagen con texto: es un contenedor con tipografías embebidas, capas, formularios AcroForm, XFA, firmas criptográficas, anotaciones con su propio modelo de datos y treinta años de decisiones heredadas. Parece simple solo porque lo abres todos los días y funciona.

    Renderizar la primera página con pdf.js te lleva una tarde. El problema empieza después.

    Que la anotación quede anclada al párrafo correcto al hacer zoom. Que la redacción elimine el texto del stream y no solo lo tape —si lo tapas, cualquiera lo copia con Ctrl+C y tienes un incidente de datos—. Que la firma se incruste sin romper la validez del documento. Y que todo eso funcione igual en Safari iOS.

    Ese es el trabajo real. Y no es trabajo de dos semanas: es trabajo de un equipo dedicado durante trimestres.

    Antes de meter una pieza así en tu aplicación, escribe qué necesitas exactamente. Suena obvio y casi nadie lo hace. En cómo aplico Spec-Driven Development antes de escribir código explico el proceso —y lo tienes entero en el libro de Spec-Driven Development—. Esta es justo la decisión donde una especificación de una página te evita elegir mal una licencia comercial.


    Qué es Apryse WebViewer y qué te ahorra

    Apryse WebViewer es un SDK comercial que monta un visor y editor de PDF en React dentro de tu aplicación, ejecutándose entero en el navegador. No necesitas un servicio de conversión detrás.

    Lo que trae de fábrica:

    • Anotación completa: resaltados, notas, dibujo, formas, comentarios.
    • Redacción real, que elimina el contenido del documento.
    • Edición de texto sobre el PDF.
    • Fill & sign para formularios y firma.
    • Búsqueda dentro del documento.
    • Cambio programático del documento cargado.
    • Soporte de más de 100 formatos: Office, imágenes, CAD. No solo PDF.

    Ese último punto suele cerrar la decisión. Cuando el cliente añade «ah, y también suben Word y planos», ya no evalúas un visor: evalúas si escribes tu propio pipeline de conversión.


    Cómo montar un visor de PDF en Next.js paso a paso

    La guía oficial de Apryse para Next.js cubre la instalación y es correcta. Lo que sigue añade lo que no te cuenta: la limpieza al desmontar, el fallo silencioso cuando path está mal y en qué casos no deberías estar leyendo este tutorial.

    1. Instalar el paquete

    npm i @pdftron/webviewer@^12
    

    Fijar la mayor te evita que un npm update te cambie la API por debajo. Este tutorial está escrito sobre la rama 12 con Next.js 16 y App Router.

    2. Copiar los assets estáticos

    Este es el paso que más gente se salta y luego pasa una tarde mirando 404 en la pestaña de red. WebViewer necesita sus propios archivos servidos estáticamente: workers, fuentes, recursos de UI.

    npx --yes cpy-cli "node_modules/@pdftron/webviewer/public/**/*" public/lib/webviewer
    

    Guárdalo como script de postinstall. Si no lo haces, funcionará en tu máquina y fallará en el primer despliegue limpio de CI:

    {
      "scripts": {
        "postinstall": "npx --yes cpy-cli \"node_modules/@pdftron/webviewer/public/**/*\" public/lib/webviewer"
      }
    }
    

    3. El componente, siempre en cliente (y el error "window is not defined")

    WebViewer toca window y el DOM en el momento de inicializarse. Si Next.js intenta renderizarlo en el servidor, revienta con el clásico ReferenceError: window is not defined.

    Aquí está el matiz que atasca a casi todo el mundo: marcar el componente con 'use client' no basta si el import es estático. Esa directiva define dónde se hidrata el componente, no impide que el módulo se evalúe al construir el bundle del servidor.

    La solución es importar el módulo dinámicamente dentro del useEffect, para que solo se resuelva en el navegador después del montaje.

    'use client'
    import { useEffect, useRef } from 'react'
    
    export default function PdfViewer() {
      const viewer = useRef(null)
    
      useEffect(() => {
        const container = viewer.current
        let instance = null
        let unmounted = false
    
        const destroy = () => {
          instance?.UI?.dispose?.()
          instance = null
          if (container) container.innerHTML = ''
        }
    
        import('@pdftron/webviewer')
          .then(({ default: WebViewer }) =>
            WebViewer(
              {
                path: '/lib/webviewer',
                licenseKey: process.env.NEXT_PUBLIC_APRYSE_LICENSE_KEY,
                initialDoc: 'https://apryse.s3.amazonaws.com/public/files/samples/WebviewerDemoDoc.pdf',
              },
              container,
            ),
          )
          .then((i) => {
            instance = i
            if (unmounted) return destroy()
            i.Core.documentViewer.addEventListener('documentLoaded', () => {
              // el documento ya está en pantalla: engancha aquí tu lógica
            })
          })
          .catch((error) => {
            console.error('WebViewer no arrancó. Revisa la opción `path`:', error)
          })
    
        return () => {
          unmounted = true
          destroy()
        }
      }, [])
    
      return <div ref={viewer} style={{ height: '100dvh' }} />
    }
    

    Cuatro detalles que conviene entender, no copiar:

    • path apunta exactamente a donde copiaste los assets en el paso 2. Si cambias la carpeta, cambia esto.
    • El array de dependencias vacío no te salva de StrictMode. En desarrollo React monta, desmonta y vuelve a montar: el efecto corre dos veces y, sin función de limpieza, acabas con dos visores peleando por el mismo div. Por eso el return del useEffect llama a UI.dispose() y vacía el contenedor. Es la parte que casi ningún tutorial escribe.
    • La bandera unmounted cubre el otro orden posible: que el usuario navegue a otra ruta antes de que resuelva el import() dinámico. Sin ella te quedas un iframe y unos workers WASM vivos en memoria.
    • La promesa resuelve con un instance que expone instance.Core —donde vive documentViewer— e instance.UI. Ese objeto es tu mando a distancia.

    Y sí, el .catch() importa: si path está mal, sin él la promesa se rechaza en silencio y te quedas mirando un div en blanco sin una sola pista en consola.

    Si necesitas la API completa, añade fullAPI: true a las opciones.

    La clave va en variable de entorno para no hardcodearla en el repo:

    NEXT_PUBLIC_APRYSE_LICENSE_KEY=tu_clave_de_trial
    

    Ojo con la etiqueta: cualquier variable NEXT_PUBLIC_ viaja al bundle del navegador, así que esto no la convierte en un secreto. En WebViewer la licencia es de cliente y va ligada a tu dominio, así que es correcto y esperado; pero si algún día metes aquí una credencial de verdad, ese secreto vive en tu backend, no en una NEXT_PUBLIC_.


    Controlar el visor desde tu propia interfaz

    Casi nadie quiere la UI del SDK tal cual: quieres tus botones, con tu marca, en tu layout.

    El patrón es guardar la instancia en estado o en una ref y llamar a sus métodos desde tus componentes. El visor deja de ser una caja negra y pasa a ser un motor que tú comandas.

    'use client'
    import { useEffect, useRef, useState } from 'react'
    
    export default function PdfWorkspace() {
      const viewer = useRef(null)
      const [instance, setInstance] = useState(null)
    
      useEffect(() => {
        const container = viewer.current
        let current = null
        let unmounted = false
    
        const destroy = () => {
          current?.UI?.dispose?.()
          current = null
          setInstance(null)
          if (container) container.innerHTML = ''
        }
    
        import('@pdftron/webviewer')
          .then(({ default: WebViewer }) =>
            WebViewer(
              {
                path: '/lib/webviewer',
                licenseKey: process.env.NEXT_PUBLIC_APRYSE_LICENSE_KEY,
                fullAPI: true,
              },
              container,
            ),
          )
          .then((i) => {
            current = i
            if (unmounted) return destroy()
            setInstance(i)
          })
          .catch((error) => {
            console.error('WebViewer no arrancó:', error)
          })
    
        return () => {
          unmounted = true
          destroy()
        }
      }, [])
    
      return (
        <>
          <button
            disabled={!instance}
            onClick={() => {
              if (!instance) return
              const { documentViewer } = instance.Core
              // llama aquí al método que necesites sobre el documento
            }}
          >
            Acción propia
          </button>
          <div ref={viewer} style={{ height: '100dvh' }} />
        </>
      )
    }
    

    Un aviso de rendimiento: este bundle es grande. No lo cargues en el layout raíz ni en una ruta que la gente visita de paso. Aíslalo en su propia ruta y deja que el router precargue lo demás — hablé de esto al analizar las navegaciones instantáneas de Next.js 16.3, y aquí la diferencia entre hacerlo bien y mal se nota en segundos, no en milisegundos.


    La parte incómoda: cuánto cuesta Apryse WebViewer

    Apryse WebViewer no es gratis: su web de precios indica paquetes de entrada desde $1.500, y el precio final es a medida. Esa es la cifra, y aquí es donde la mayoría de tutoriales se callan.

    El importe depende de las features que actives, del volumen de documentos y de si la solución es cliente o servidor. No hay tarifa pública por tramos: hay que pedir presupuesto.

    Antes de pagar puedes probarlo. Apryse ofrece un trial gratuito, y su documentación de instalación te pide obtener una trial key en el portal de desarrolladores como paso previo. No te fíes de las condiciones que leas en un blog —incluido este—: mira el portal, que es donde cambian.

    Vas a encontrar otras cifras circulando por foros y comparativas. Ninguna sale de Apryse. Ignóralas y pide presupuesto: es la única cifra que vale para tu caso.


    ¿Cuándo NO usar Apryse?

    Si lo único que necesitas es mostrar un PDF en modo lectura, esto es un cañón para matar una mosca.

    Para eso tienes pdf.js de Mozilla, o react-pdf —construido encima— si quieres la integración con componentes ya resuelta. Son gratis, open source, maduros y te resuelven ese caso entero. Meter un SDK comercial ahí es quemar presupuesto y añadir peso al bundle sin ganar nada.

    Esta es la comparativa que a mí me habría ahorrado dos días de evaluación:

    Necesidad pdf.js react-pdf Apryse WebViewer
    Renderizar, paginar, zoom ✅ ✅ ✅
    Búsqueda en el documento ✅ ⚠️ manual ✅
    Componentes React listos ❌ ✅ ✅
    Anotaciones persistentes ❌ ❌ ✅
    Redacción real (borra del stream) ❌ ❌ ✅
    Edición de texto sobre el PDF ❌ ❌ ✅
    Fill & sign / firma ❌ ❌ ✅
    Office, imágenes, CAD (100+ formatos) ❌ ❌ ✅
    Licencia Apache 2.0 MIT Comercial
    Coste Gratis Gratis Desde $1.500, a medida

    Léela en diagonal y verás el patrón: las tres primeras filas son un empate, y todo lo demás es una columna sola. Si tu requisito vive en las tres primeras filas, ya tienes tu respuesta y es gratis.

    Apryse gana en un escenario concreto: cuando el documento es parte del flujo de negocio. Cuando el usuario tiene que anotar, redactar, rellenar, firmar o editar, y ese flujo es lo que el cliente está pagando.

    Y ahí el cálculo no es "SDK caro contra librería gratis". Es esto: cuántos meses de ingeniería cuesta construir y mantener redacción, anotaciones y firma bien hechas, contra el precio de licenciarlo.

    Cuando lo planteas así, la respuesta suele ser evidente. El coste no es el SDK. Es el tiempo que no gastas.


    Qué hacer hoy

    Instala el trial, copia los assets, monta el componente de arriba y ábrelo con un PDF real de tu cliente. No el de ejemplo: uno feo, escaneado, de 80 páginas.

    En veinte minutos sabrás si esto resuelve tu problema o si te sobra con react-pdf. Esa decisión, tomada con el visor delante y no leyendo comparativas, vale más que cualquier post.

    Y si lo que quieres es integrar piezas grandes como esta sin perder tres días leyendo documentación, ese es exactamente el flujo que enseño en el curso Construye con IA: especificar primero, delegar la integración después. La metodología completa está en el libro de Spec-Driven Development, y si prefieres hacerlo acompañado, en Dominicode Labs trabajamos integraciones como esta sobre proyectos reales.


    Preguntas frecuentes

    ¿Apryse WebViewer es gratis?

    No. Es un SDK comercial. Sí ofrece un trial gratuito para validar si encaja con tu caso: su documentación de instalación te pide obtener una trial key en el portal de desarrolladores antes de empezar. Para producción, su web de precios indica paquetes de entrada desde $1.500 (consultado en julio de 2026), con precio final a medida según las features que actives, el volumen de documentos y si el despliegue es en cliente o en servidor. Las condiciones exactas del trial las marca su portal, no los blogs.

    ¿Se puede usar Apryse WebViewer con el App Router de Next.js?

    Sí, con dos condiciones. El componente que lo monta debe llevar la directiva 'use client' y el import del paquete tiene que ser dinámico dentro del useEffect, no estático en la cabecera del archivo. Así el bundle del servidor nunca evalúa el SDK. Además tienes que copiar los assets estáticos del paquete a public/lib/webviewer y apuntar la opción path a esa ruta.

    ¿Por qué me da el error "window is not defined" al integrar el visor?

    Porque Next.js está intentando ejecutar el SDK durante el renderizado en servidor, donde no existe el objeto window. Marcar el componente como cliente no basta si el import es estático: el módulo se evalúa igualmente al construir. La solución es cargarlo con import('@pdftron/webviewer') dentro del useEffect, de forma que solo se resuelva en el navegador después del montaje.

    ¿Qué alternativa gratuita hay a Apryse?

    pdf.js de Mozilla y react-pdf, que está construido encima. Ambos son open source y cubren perfectamente la visualización de documentos: renderizado, paginación, zoom y búsqueda básica. Donde no llegan es en el flujo completo de trabajo con documentos —redacción que borra contenido de verdad, edición de texto, fill & sign, anotaciones persistentes con su modelo de datos—. Si tu requisito es leer, usa las gratuitas. Si tu requisito es operar sobre el documento, compara con Apryse.

    ¿Sirve solo para PDF?

    No. WebViewer soporta más de 100 formatos, incluidos documentos de Office, imágenes y archivos CAD, y los renderiza en el mismo visor sin necesidad de un servicio de conversión en el servidor. Suele ser el factor decisivo cuando los usuarios suben lo que tienen a mano y no un PDF bien generado.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • De callbacks a Signals: la reactividad real del frontend

    De callbacks a Signals: la reactividad real del frontend

    Un excliente me escribió hace años, angustiado. Su carrito de compras mostraba 3 artículos en el header, pero el checkout decía que había 5. Los clientes se quejaban en soporte y algunos abandonaban la compra.

    Revisé el código. Puro estilo jQuery: DOM manipulado a mano, evento por evento. Un event listener actualizaba el contador del header. Otro, completamente separado, actualizaba el resumen del checkout. Nadie los había conectado entre sí — y ahí estaba el problema real: cero programación reactiva, cero garantía de que el estado y la interfaz dijeran la misma verdad.

    Cuando alguien hacía clic dos veces seguidas y rápido, un listener terminaba antes que el otro. El total quedaba repartido entre cuatro variables sueltas, cada una con su propia versión de la verdad. Pasé tres horas arreglando algo que debería haberme tomado diez minutos. No porque el bug fuera complejo — porque nada en el código garantizaba que la interfaz reflejara el estado real.

    Llevamos veinte años resolviendo ese mismo problema con herramientas distintas. Primero fueron callbacks manuales sobre el DOM. Luego llegó el Virtual DOM. Ahora, señales. Cada era resolvió lo que la anterior no pudo — y entender por qué importa más que memorizar sintaxis nueva cada dos años.


    Era 1: callbacks manuales y el DOM que se te olvida sincronizar

    En los tiempos de jQuery — y del DOM vanilla antes de eso — la única forma de reaccionar a un evento era escucharlo y mutar el DOM a mano. Tú decidías qué elemento tocar, cuándo y con qué valor.

    Toma el ejemplo clásico: un contador de carrito con tres elementos que dependen del mismo dato.

    let count = 0;
    
    const counterEl = document.querySelector('#counter');
    const totalEl = document.querySelector('#total');
    const shippingMsgEl = document.querySelector('#shipping-msg');
    
    document.querySelector('#add-btn').addEventListener('click', () => {
      count++;
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      shippingMsgEl.textContent = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    });
    
    document.querySelector('#remove-btn').addEventListener('click', () => {
      count = Math.max(0, count - 1);
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      // shippingMsgEl no se actualiza aquí. Nadie lo notó en code review.
    });
    

    Mira el comentario en la última línea. Ese es, casi literal, el bug que revisé en el carrito de mi excliente.

    No es un error de sintaxis — el código compila, pasa QA si nadie prueba el camino de "quitar un producto cuando ya tenías envío gratis". El bug vive en la cabeza del developer: hay que acordarse de tocar los tres elementos en cada handler que mueva ese estado.

    La ventaja de este modelo es real: control total, cero abstracciones, cero curva de aprendizaje. Para un widget aislado — un acordeón, un modal, un tooltip — sigue siendo la opción correcta hoy mismo.

    El problema aparece en cuanto el estado deja de ser trivial:

    • Cada elemento dependiente necesita su propia línea de sincronización, repetida en cada handler que toque ese estado.
    • El estado vive disperso: a veces en el DOM (el.textContent), a veces en variables sueltas, a veces en atributos data-*.
    • Los listeners no se limpian solos. En una SPA que monta y desmonta vistas, cada addEventListener sin su removeEventListener es un memory leak esperando a pasar factura.

    Esto nunca fue un problema de jQuery. Fue un problema de arquitectura: nada en el modelo te obligaba a centralizar el estado ni a declarar sus dependencias. Cada developer inventaba su propia disciplina — y la disciplina, a escala de equipo, no escala.

    Era 2: Virtual DOM y el modelo declarativo

    React cambió la pregunta. En lugar de "¿qué elemento del DOM tengo que tocar?", pasó a ser "¿cómo se ve la UI dado este estado?". Tú describes el resultado final; el framework decide cómo llegar ahí.

    function Counter() {
      const [count, setCount] = useState(0);
      const total = (count * 19.99).toFixed(2);
      const shippingMsg = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    
      return (
        <div>
          <p>{count}</p>
          <p>${total}</p>
          <p>{shippingMsg}</p>
          <button onClick={() => setCount(c => c + 1)}>Añadir</button>
          <button onClick={() => setCount(c => Math.max(0, c - 1))}>Quitar</button>
        </div>
      );
    }
    

    El bug del carrito es estructuralmente imposible aquí. total y shippingMsg se calculan en la misma función, a partir del mismo count, cada vez que el componente se ejecuta. No hay "actualizar" — hay "recalcular todo desde cero", así que no hay forma de que uno se sincronice y el otro se olvide.

    Ahí está la clave del Virtual DOM. React no toca el DOM real en cada cambio. Construye un árbol en memoria — objetos JavaScript planos que describen cómo debería verse la UI — y lo compara contra el árbol anterior. Ese proceso se llama reconciliation, y el algoritmo de comparación es el diffing: detecta qué nodos cambiaron, cuáles se reutilizan, y calcula el mínimo de operaciones para que el DOM real refleje el nuevo árbol. Solo entonces toca el DOM — y solo donde hace falta.

    Es un modelo declarativo y predecible. Pero el coste real no es gratis, y es lo que casi nadie menciona en los tutoriales de introducción: cada cambio de estado re-ejecuta la función completa del componente y, por defecto, la de sus hijos.

    En un árbol de cuarenta componentes anidados, un solo tecleo puede disparar cuarenta re-renders y cuarenta diffs — la mayoría comparando nodos que ni siquiera cambiaron.

    La respuesta del ecosistema fue la memoization: memo(), useMemo(), useCallback(). Son parches necesarios para un problema que el propio modelo introduce: no sabes qué cambió hasta que recalculas y comparas. Memoizar es responsabilidad manual otra vez — la misma que el Virtual DOM prometía eliminar, solo que movida un nivel más arriba en el árbol.

    Era 3: reactividad fina — el grafo en vez del árbol

    Los signals no comparan nada. No hay árbol virtual, no hay diffing, no hay re-render de una función completa. Un signal es una caja que guarda un valor y sabe, con precisión, quién depende de él.

    import { Component, signal, computed, effect } from '@angular/core';
    
    @Component({
      selector: 'app-cart-counter',
      template: `
        <p>{{ count() }}</p>
        <p>${{ total() }}</p>
        <p>{{ shippingMsg() }}</p>
        <button (click)="count.set(count() + 1)">Añadir</button>
        <button (click)="count.set(count() - 1)">Quitar</button>
      `,
    })
    export class CartCounterComponent {
      count = signal(0);
    
      total = computed(() => (this.count() * 19.99).toFixed(2));
    
      shippingMsg = computed(() =>
        this.count() >= 5
          ? '¡Envío gratis!'
          : `Añade ${5 - this.count()} más para envío gratis`
      );
    
      constructor() {
        effect(() => {
          console.log(`Carrito: ${this.count()} items — $${this.total()}`);
        });
      }
    }
    

    Cuando count cambia, Angular no re-ejecuta el componente entero ni reconstruye ningún árbol para comparar. total y shippingMsg ya saben que dependen de count — lo registraron la primera vez que se ejecutaron, al construirse el grafo reactivo. Angular actualiza exactamente el nodo del DOM ligado a cada binding. Nada más se mueve.

    Esto es reactividad fina (fine-grained reactivity): la granularidad de la actualización no es el componente, ni el subárbol — es el binding individual. Angular v22 lleva esto hasta el final siendo zoneless por defecto: ya no depende de Zone.js interceptando cada setTimeout o evento del navegador para saber cuándo revisar cambios. El grafo de signals es la única fuente de verdad sobre qué actualizar y cuándo.

    Angular no inventó este modelo — lo adoptó y lo llevó a producción a escala. Solid.js lo demostró primero, sin Virtual DOM desde el diseño inicial. Svelte llega a un resultado parecido compilando la reactividad en tiempo de build. Los tres coinciden en el mismo diagnóstico: comparar árboles es trabajo evitable si sabes de antemano quién depende de quién.

    Si quieres ver cada primitiva documentada en detalle, la guía oficial de Angular Signals cubre signal(), computed() y effect() con más profundidad de la que cabe en un post.

    Si quieres ver cómo se construye ese grafo de dependencias paso a paso — incluyendo los casos raros donde un effect() se dispara más veces de las que esperas — lo cubrí a fondo en el post sobre el grafo reactivo de Angular Signals.

    En el curso de Angular Moderno construimos este modelo mental desde cero, con proyectos reales donde pasar de Zone.js a zoneless cambia decisiones de arquitectura, no solo de sintaxis.

    Los tres paradigmas, uno al lado del otro

    Modelo mental Cómo detecta cambios Granularidad de la actualización Coste computacional Dónde brilla
    Callbacks (jQuery / DOM imperativo) Tú mutas el DOM a mano, evento por evento No detecta nada — el developer decide cuándo actualizar La que tú programes, elemento por elemento Bajo por operación, alto en mantenimiento y bugs de sincronización Widgets aislados, prototipos, páginas sin estado compartido
    Virtual DOM (React) La UI es una función pura del estado Diffing — compara árbol virtual anterior vs. nuevo Por componente/subárbol, tras re-ejecutar y comparar Re-ejecuta la función de render completa y diffea en cada cambio Apps con estado complejo, equipos grandes, ecosistema maduro
    Signals (Angular, Solid, Svelte) Grafo de dependencias reactivas Suscripción directa — el signal sabe quién lo consume El binding o nodo exacto del DOM que depende del valor Solo se ejecuta lo que realmente cambió UI de alta frecuencia de actualización, listas grandes, apps sensibles a rendimiento

    Por qué la reactividad fina no es una moda

    Cada era resolvió el cuello de botella real de la anterior — no la anterior en abstracto, la anterior en producción.

    Los callbacks resolvieron "cómo reacciono a un evento del usuario". Fue suficiente mientras la UI tenía poco estado compartido. Dejó de serlo en cuanto una sola acción tenía que actualizar cinco sitios distintos de la pantalla.

    El Virtual DOM resolvió "cómo mantengo la UI declarativa sin perder la cordura sincronizando elementos a mano". A cambio, aceptó un coste: recalcular y comparar árboles que, la mayoría de las veces, apenas habían cambiado.

    Signals resuelve el cuello de botella que el Virtual DOM introdujo: cómo evitar recalcular y comparar lo que ya sabías que no había cambiado. No es una versión "más rápida" de React. Es una respuesta distinta a la misma pregunta de fondo: ¿qué es lo mínimo que tengo que actualizar para que la UI diga la verdad?

    Esto no significa que el Virtual DOM esté acabado, ni que debas reescribir tu app de React mañana.

    Significa que si estás arrancando un proyecto hoy, entender este modelo ya no es opcional — es la diferencia entre construir sobre un patrón que resuelve el problema en su raíz o sobre uno que lo parchea con memoization.

    Esta decisión de arquitectura — dónde vive el estado, cómo fluye, qué parte del sistema es responsable de mantenerlo sincronizado con la UI — es exactamente el tipo de decisión que trato en el post sobre Clean Architecture para frontend con IA: la reactividad que elijas no es un detalle de implementación, es una decisión que carga con consecuencias durante años.

    Si vas a construir con signals en producción, en algún momento necesitarás verificar que esos computed() y effect() se comportan como esperas bajo distintos escenarios — eso es justo lo que trabajamos con casos reales en el curso de Testing en Angular con Jest y Testing Library.

    Y si quieres discutir esto con otros developers que están tomando las mismas decisiones ahora mismo, en Dominicode Labs es donde pasa esa conversación cada semana.

    Preguntas frecuentes sobre programación reactiva en el frontend

    ¿Qué es la programación reactiva?

    Es el paradigma en el que la interfaz se actualiza automáticamente cuando cambia el estado del que depende, sin que el desarrollador tenga que sincronizarla a mano evento por evento. Los tres modelos de este post — callbacks, Virtual DOM y signals — son formas distintas de resolver ese mismo problema, con más o menos reactividad real incorporada al framework.

    ¿El Virtual DOM está muerto?

    No. Sigue siendo el modelo dominante en producción — React tiene el ecosistema, el talento disponible y millones de líneas de código funcionando con él hoy. Lo que cambió es que ya no es la única opción seria para UI compleja: Signals, Solid.js y Svelte demuestran que el diffing es una solución al problema, no la única posible.

    ¿Los Signals reemplazan a React?

    No en el sentido de que React vaya a desaparecer. Angular con Signals, Solid.js y Svelte son alternativas con un modelo distinto, no reemplazos del ecosistema React. Sí es cierto que la presión competitiva ya empujó a React hacia herramientas como React Compiler, que intenta automatizar la memoization que antes hacías a mano.

    ¿Qué es la reactividad fina (fine-grained reactivity)?

    Es un modelo donde cada pieza de estado (signal) mantiene una lista explícita de quién depende de ella — otros signals derivados (computed) o efectos secundarios (effect). Cuando el valor cambia, solo se re-ejecuta lo que está suscrito a ese valor específico, sin comparar árboles ni recalcular lo que no depende de ese dato.

    ¿Angular usa Virtual DOM?

    No, y nunca lo usó. Angular usaba Zone.js y un mecanismo de change detection basado en recorrer el árbol de componentes buscando cambios. Con Signals y el modo zoneless, por defecto desde Angular v22, Angular elimina también ese recorrido: el grafo de signals le dice exactamente qué actualizar, sin Zone.js y sin diffing.

    ¿Debo migrar mi app de React a Signals?

    No si tu app funciona bien y el equipo domina React. La reactividad fina brilla en escenarios concretos: dashboards con actualizaciones muy frecuentes, listas grandes, apps donde el rendimiento de render es un cuello de botella medido, no sospechado. Si estás empezando un proyecto nuevo, sí vale la pena evaluar Angular v22 con Signals como opción seria.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Next.js 16.3 Instant Navigations: cero esperas al navegar

    Next.js 16.3 Instant Navigations: cero esperas al navegar

    Llevaba semanas con una queja recurrente de un cliente. Su app en Next.js con App Router se sentía lenta. No el server, no la base de datos — las navegaciones. Hacías clic en un enlace y durante un segundo entero no pasaba nada. Literalmente nada. Next.js 16.3 Instant Navigations es la respuesta directa a ese problema.

    En un modelo server-driven, cada navegación implica un roundtrip de red. El cliente espera, el servidor procesa, responde, aparece la página. En una SPA ese segundo no existe — el cliente muestra una shell inmediata mientras los datos llegan. Next.js había apostado por el servidor, pero el coste en percepción de velocidad era real.

    Nota de versión: Next.js 16.3 está actualmente en preview (instalable con npm install next@preview). Las APIs que describo aquí son las publicadas el 25 de junio de 2026 en el blog oficial de Next.js. Pueden cambiar antes del release estable.


    El problema que 16.3 viene a resolver

    En Next.js clásico con Server Components, el flujo de navegación era este:

    1. El usuario hace clic en un enlace.
    2. El navegador no hace nada visible.
    3. El servidor procesa la ruta, genera el HTML, responde.
    4. La página aparece.

    Para apps orientadas a contenido — un blog, un periódico — esto funciona. Para dashboards, herramientas internas, apps tipo SaaS, ese segundo de nada destruye la experiencia.

    Las SPAs resuelven esto de otra forma: descargan el código de cada ruta de antemano y muestran una shell inmediata mientras los datos llegan. Sensación instantánea. Next.js tenía el prefetching, pero lo hacía a nivel de link individual, lo que generaba decenas de peticiones al servidor cada vez que el usuario hacía scroll por una lista de enlaces.

    16.3 cambia los dos flancos del problema.


    Cómo funciona Instant Navigations

    Paso 1: habilitar Cache Components

    Todo empieza con un flag en next.config.ts:

    // next.config.ts
    import type { NextConfig } from 'next';
    
    const nextConfig: NextConfig = {
      cacheComponents: true,
    };
    
    export default nextConfig;
    

    Este flag activa el modelo de Cache Components — el nuevo paradigma de Next.js donde el caching es explícito con 'use cache' en lugar de implícito y confuso como en versiones anteriores. Con él habilitado, Next.js puede generar un "shell" para cada ruta: la parte de la UI que puede renderizarse sin esperar al servidor.

    Paso 2: elegir el modo de cada ruta — Stream, Cache o Block

    Con Cache Components activo, cuando una ruta hace await a datos del servidor, Next.js en desarrollo te muestra un panel llamado Instant Insights. Este panel detecta qué rutas están bloqueando la navegación y te da tres opciones:

    Stream con <Suspense>

    La ruta muestra inmediatamente una shell con estados de carga, y los datos se van incluyendo por streaming conforme llegan:

    // app/products/[id]/page.tsx
    import { Suspense } from 'react';
    import { ProductDetail } from './product-detail';
    import { ProductSkeleton } from './product-skeleton';
    
    export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
      const { id } = await params;
      return (
        <div>
          <h1>Producto</h1>
          <Suspense fallback={<ProductSkeleton />}>
            <ProductDetail id={id} />
          </Suspense>
        </div>
      );
    }
    

    La navegación es inmediata. El usuario ve la shell con el skeleton. Los datos llegan después. Sensación de SPA.

    Cache con 'use cache'

    Si la ruta depende de datos que se pueden cachear, marcas la función con 'use cache' y Next.js sirve el resultado cacheado de forma instantánea en navegaciones posteriores:

    // app/dashboard/analytics/page.tsx
    import { unstable_cacheLife as cacheLife } from 'next/cache';
    import { Suspense } from 'react';
    
    async function AnalyticsSection() {
      'use cache';
      cacheLife('minutes'); // TTL de caché explícito
      const data = await fetchAnalytics();
      return <Chart data={data} />;
    }
    
    export default function AnalyticsPage() {
      return (
        <Suspense fallback={<AnalyticsSkeleton />}>
          <AnalyticsSection />
        </Suspense>
      );
    }
    

    El usuario ve el contenido cacheado de forma inmediata. Si el cache está fresco, la experiencia es idéntica a una SPA.

    Block: cuando quieres que la navegación espere al servidor

    Hay casos donde no quieres mostrar una shell. Un blog no debería mostrar un spinner donde va el artículo — o muestras el artículo o no navegas. Para esos casos, exportas instant = false:

    // app/blog/[slug]/page.tsx
    export const instant = false; // Esta ruta bloquea hasta tener respuesta del servidor
    
    export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
      const { slug } = await params;
      const post = await getPost(slug);
      return <Article post={post} />;
    }
    

    Next.js deja de reportar esta ruta como problema de rendimiento. Has decidido conscientemente que prefieres la espera a mostrar una UI incompleta. La diferencia es que ahora es una decisión explícita, no un comportamiento por defecto que no entiendes.


    Partial Prefetching: prefetchear smarter, no harder

    El segundo gran cambio es cómo Next.js hace prefetching.

    En 16.2, si tenías una lista de veinte enlaces a /chat/[id], Next.js enviaba veinte peticiones de prefetch al servidor — una por link visible en el viewport. Ineficiente y costoso.

    En 16.3, con Partial Prefetching habilitado, Next.js prefetchea un shell por ruta, no por link. Veinte links a /chat/[id] generan exactamente una petición: la del shell de /chat/[id]. Ese shell se cachea en el cliente durante toda la sesión.

    Para habilitarlo:

    // next.config.ts
    import type { NextConfig } from 'next';
    
    const nextConfig: NextConfig = {
      cacheComponents: true,
      partialPrefetching: true,
    };
    
    export default nextConfig;
    

    Prefetching por link cuando necesitas más

    El Partial Prefetching es conservador por diseño — solo prefetchea el shell. Si quieres que un link concreto prefetchee también contenido específico, añades prefetch={true} al componente <Link>:

    // Una lista donde quieres que el header del chat se vea instantáneamente
    export function ChatList({ chats }: { chats: Chat[] }) {
      return (
        <ul>
          {chats.map(chat => (
            <li key={chat.id}>
              {/* Prefetch completo para este link */}
              <Link href={`/chat/${chat.id}`} prefetch={true}>
                {chat.title}
              </Link>
            </li>
          ))}
        </ul>
      );
    }
    

    Y si quieres que el prefetch incluya contenido dinámico de request-time (no solo build-time), lo permites explícitamente en la ruta:

    // app/chat/[id]/page.tsx
    export const prefetch = 'allow-runtime';
    

    La ventaja respecto al comportamiento anterior: ya no es todo o nada. Tienes granularidad real.


    Navigation Inspector: ve el shell antes de que el usuario llegue

    El Navigation Inspector es una herramienta de las Next.js DevTools que pausa cualquier navegación en el momento exacto del shell — antes de que lleguen los datos del servidor — mostrando visualmente qué partes de la ruta son instantáneas y cuáles requieren una petición de red.

    En la práctica: haces clic en un enlace, el inspector lo detiene en el shell, ves el mapa completo de tu ruta. Cuando haces clic en "Resume", la navegación completa. Especialmente útil para identificar componentes que bloquean la navegación porque hacen await sin <Suspense> ni 'use cache'.


    Testing: el helper instant() para Playwright

    Para que las mejoras de rendimiento no retrocedan con refactorizaciones futuras, Next.js 16.3 incluye un test helper para Playwright:

    // tests/navigation.spec.ts
    import { expect, test } from '@playwright/test';
    import { instant } from '@next/playwright';
    
    test('el header del producto aparece sin esperar al servidor', async ({ page }) => {
      await page.goto('/products/shoes');
    
      // Todo lo que esté dentro de este bloque debe ser visible SIN red
      await instant(page, async () => {
        await page.click('a[href="/products/hats"]');
        await expect(page.locator('h1')).toContainText('Baseball Cap');
        await expect(page.getByText('Checking inventory...')).toBeVisible();
      });
    
      // Esto sí puede esperar al servidor
      await expect(page.getByText('12 in stock')).toBeVisible();
    });
    

    instant() es el equivalente a un test de performance integrado en tu suite de e2e. Si un refactor convierte una ruta Stream en una ruta bloqueante, el test falla. Sin sorpresas en producción.

    Si ya tienes tests de Angular y quieres aplicar la misma mentalidad a tus proyectos — testear comportamiento, no implementación — el curso de Testing en Angular te da esa base de forma sólida con Jest y Testing Library.


    Comparativa: antes vs. después

    Aspecto Next.js 16.2 Next.js 16.3
    Comportamiento por defecto Bloquea hasta respuesta del servidor Stream o Cache para navegación inmediata
    Prefetching 1 petición por link en viewport 1 shell por ruta, reutilizado entre links
    Control por ruta No hay export const instant = false para rutas bloqueantes
    Herramienta de diagnóstico Ninguna Instant Insights + Navigation Inspector
    Testing de regresiones Manual instant() helper para Playwright
    Configuración Implícita y confusa Explícita con cacheComponents y partialPrefetching

    Cómo empezar hoy mismo

    Para probar Instant Navigations en un proyecto existente:

    1. Instala el preview:

      npm install next@preview
      
    2. Habilita los flags en next.config.ts:

      const nextConfig: NextConfig = {
        cacheComponents: true,
        partialPrefetching: true,
      };
      
    3. Arranca el servidor de desarrollo. Verás el panel Instant Insights con las rutas que están bloqueando la navegación.

    4. Identifica, decide y verifica. Para cada ruta bloqueante, elige Stream, Cache o Block. El Navigation Inspector confirma que el shell funciona antes de ir a producción.

    El equipo de Vercel lo validó en v0 — su propia app — antes del release. Los tiempos de navegación bajaron significativamente en las rutas que adoptaron el nuevo modelo.

    Si quieres ver cómo se integra este tipo de arquitectura con IA y streaming en tiempo real, en Dominicode Labs estamos construyendo proyectos que combinan Next.js con streaming de LLMs — exactamente el tipo de apps donde Instant Navigations marca la diferencia más visible. Para entender la Claude API con TypeScript antes de integrarla, este crash course es el punto de partida.


    Casos de uso donde esto cambia más

    Dashboards con datos en tiempo real. Cada cambio de sección era un segundo de espera. Con Stream + Suspense, el layout del dashboard aparece inmediatamente y los datos llegan después.

    Apps de chat o mensajería. Con Partial Prefetching, navegar entre conversaciones — aunque sean decenas — genera una sola petición de prefetch por ruta, no una por cada enlace visible.

    E-commerce. Las páginas de producto pueden mostrar la estructura (imagen placeholder, nombre, botón "Añadir al carrito") de forma instantánea mientras el inventario y el precio se cargan.

    Herramientas internas con muchas secciones. El menú lateral con 30 links ya no genera 30 peticiones de prefetch al cargar la página.

    Si trabajas con Astro para partes estáticas de tu sitio y Next.js para las dinámicas, el análisis de Astro v7 te ayuda a decidir qué encaja en cada capa.


    FAQ

    ¿Instant Navigations funciona con el Pages Router o solo con App Router?

    Solo con App Router. Cache Components y el modelo de shells requieren Server Components, que no existen en Pages Router. Si aún tienes un proyecto en Pages Router, esta es una razón más para evaluar la migración.

    ¿cacheComponents: true cambia el comportamiento de caching de mis datos?

    Sí, de forma intencional. El nuevo modelo hace el caching explícito: nada se cachea por defecto a menos que uses 'use cache'. Si venías de fetch con opciones implícitas de cache, tendrás que revisar tu estrategia de datos. Es un cambio de paradigma, no solo un flag de navegación.

    ¿El Partial Prefetching incrementa el coste de servidor?

    Al contrario. En 16.2, con veinte links en pantalla tenías veinte peticiones de prefetch. En 16.3, con Partial Prefetching, tienes una petición por ruta distinta. En un escenario real con listas de items que apuntan al mismo route pattern, la reducción de peticiones puede ser del 90%.

    ¿Puedo usar <Link prefetch={true}> para todo y obtener el comportamiento anterior?

    Técnicamente sí, pero estarías ignorando el punto. El comportamiento anterior era ineficiente. <Link prefetch={true}> existe para casos específicos donde necesitas prefetchear más que el shell en un link concreto — no como reemplazo global del viejo modelo.

    ¿Cuándo sale el release estable de Next.js 16.3?

    No hay fecha oficial confirmada. El equipo indica que están resolviendo issues conocidos (algunos casos con Safari en Instant Insights, y rutas bloqueantes que no se reportan correctamente con Partial Prefetching activo). La recomendación es probar el preview en proyectos de desarrollo, no en producción.

    ¿Puedo escribir tests con instant() antes del release estable?

    Sí. El paquete @next/playwright ya incluye el helper y es seguro usarlo en tu suite de e2e. Si el release estable cambia el comportamiento, los tests te lo dirán antes de que llegue a producción.


    Next.js 16.3 no reinventa el framework. Lo que hace es cerrar la brecha más molesta que tenían los Server Components: la sensación de lentitud al navegar. Stream, Cache y Block son tres palabras, pero detrás hay un modelo de pensamiento claro sobre qué parte de tu UI puede ser instantánea y cuál no.

    La clave no está en activar los flags y esperar magia. Está en recorrer tus rutas con el Navigation Inspector, entender qué está bloqueando, y tomar la decisión correcta para cada una.

    Si tu próximo proyecto combina Next.js con agentes de IA o herramientas de Claude Code, en el curso Construye con IA vemos exactamente cómo estructurar apps que necesitan streaming, caché inteligente y navegación fluida desde el primer día.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Cómo funcionan los Signals en Angular 22 y React 19

    Cómo funcionan los Signals en Angular 22 y React 19

    Signals en Angular 22 y React 19: el nuevo modelo de reactividad

    Tiempo estimado de lectura: 4 min

    Ideas clave

    • Reactividad de grano fino actualiza solo los nodos del DOM que dependen de un valor.
    • Angular 22 introduce Signals explícitos, elimina Zone.js y ofrece formularios sincronizados basados en Signals.
    • React 19 apuesta por optimizaciones vía compilador y el hook use() en lugar de un primitivo signal.
    • Elegir entre ambos depende de control/depurabilidad (Angular) vs. fricción y compatibilidad con código existente (React).

    Tabla de contenidos

    Signals en Angular 22 y React 19: el nuevo modelo de reactividad es la discusión que está redefiniendo cómo pensamos la UI: menos trozos de árbol reevaluados, más actualizaciones puntuales y menos sorpresas en producción. Si tu equipo decide entre control explícito o automatización por compilador, este artículo te da criterios prácticos y ejemplos reales para elegir con criterio.

    Resumen rápido (lectores con prisa)

    Fine-grained reactivity actualiza solo dependencias directas. Angular 22 introduce Signals (signal(), computed(), effect()) y formulas sin Zone.js. React 19 usa el React Compiler para inferir memoización y añade use() para leer Promises/recursos en render. Ambos mejoran escalabilidad; Angular es explícito y más trazable, React reduce fricción de adopción.

    Signals en Angular 22 y React 19: el nuevo modelo de reactividad (explicación rápida)

    La reactividad de grano fino significa actualizar únicamente el nodo del DOM que depende de un valor concreto. Angular 22 lo hace declarando Signals (signal(), computed(), effect()), eliminando Zone.js y ofreciendo formularios basados en Signals. React 19 opta por no añadir un primitivo signal; en su lugar usa el React Compiler para inferir memoización y añade el hook use() para leer Promises/recursos en render. Documentación oficial Angular. Blog oficial React 19.

    ¿Por qué importa la reactividad de grano fino?

    Los problemas reales aparecen en aplicaciones con alta densidad de datos:

    • Dashboards financieros con cientos de celdas que actualizan simultáneamente.
    • Formularios complejos con validaciones cruzadas y campos dependientes.
    • UIs que requieren latencia mínima y CPU predecible en clientes de bajo rendimiento.

    La solución tradicional (Virtual DOM diffs o Zone.js) escala mal: consumes CPU revisando cosas que no cambiaron. Fine-grained reactivity evita ese trabajo inútil.

    Angular 22: Zoneless, Signals y formularios sincronizados

    Angular reescribió su motor de detección. Resultado práctico:

    • Renderizado Zoneless: sin interceptar microtasks; si no cambia un Signal, no hay re-render.
    • Signals explícitos: control total sobre qué es reactivo y cuándo muta.
    • Signal-based Forms: lectura síncrona del estado del formulario, menos RxJS, menos suscripciones que se filtran.

    Ejemplo Angular

    import { signal } from '@angular/core';
    
    const count = signal(0);
    count.set(count() + 1); // solo actualiza los lectores de `count`

    Para convivir con código existente, Angular ofrece utilidades toSignal() / toObservable(), facilitando migraciones incrementales. Guía.

    Ventajas concretas: trazabilidad, depuración directa (sabes qué mutó), rendimiento determinista. Coste: curva de aprendizaje y refactor en bases de código grandes.

    React 19: Reactividad inferida vía compilador y hook use()

    React evita imponer nuevos primitivos. Estrategia:

    • React Compiler: analiza en build y genera memos/mecanismos de actualización automáticos.
    • use(): permite consumir Promises o recursos directamente en render, funcionando con <Suspense> para carga declarativa.
    • Server Actions / useActionState: reduce boilerplate del ciclo formulario → servidor → feedback.

    Ejemplo React

    function Product({ id }) {
      const product = use(fetchProduct(id)); // Suspense maneja loading
      return <div>{product.name}</div>;
    }

    Ventajas: baja fricción de adopción; equipo no reescribe mentalmente la app. Coste: optimizaciones «invisibles» por el compilador que pueden complicar diagnóstico fino; depuración menos directa que en Angular.

    React Suspense referencia

    Comparativa práctica (qué esperar en producción)

    • Performance pura: ambos escalan mucho mejor que modelos antiguos. Angular da mayor predictibilidad por su modelo explícito; React consigue grandes ganancias sin romper DX.
    • Depuración: Angular facilita trazar el origen del update; en React necesitas entender qué transformó el compilador.
    • Migración: Angular exige trabajo incremental (conversión de formularios y algunos patrones de RxJS). React permite migración más suave, porque el compilador optimiza el código existente.
    • Formularios complejos: Angular gana por tipado y sincronía; React compensa con Server Actions para patrones CRUD.

    Recomendaciones prácticas para equipos

    1. Haz un piloto con módulos concretos. No migres todo de golpe.
    2. Para UIs de alta densidad de datos, prioriza Angular 22 si necesitas control y trazabilidad estricta.
    3. Si tu stack ya es Next.js / SSR y quieres mejorar rendimiento sin reeducar al equipo, React 19 es opción pragmática.
    4. Añade pruebas de rendimiento (microbenchmarks) y observabilidad: mide renders por segundo, tamaño de paint y memoria.
    5. Documenta patrones: en Angular, establece cómo y cuándo crear Signals; en React, especifica cómo instrumentar y auditar transformaciones del compilador.

    Conclusión práctica

    Signals en Angular 22 y React 19 solucionan el mismo problema con filosofías distintas: Angular te da el control explícito; React te lo facilita automáticamente. No hay «mejor» universal: hay mejor para tu equipo. Si quieres predictibilidad y depurabilidad en sistemas críticos, apuesta por Angular 22. Si prefieres un camino de menor fricción y eres heavy-SSR, React 19 acelera el time-to-market. Dominar fin-grained reactivity es ahora requisito, no lujo.

    FAQ

    ¿Qué es reactividad de grano fino?

    La reactividad de grano fino actualiza solo los nodos del DOM que dependen de un valor específico, en lugar de reevaluar grandes porciones del árbol. Reduce trabajo innecesario de CPU y mejora latencia en UIs densas.

    ¿Cómo difiere Angular 22 del modelo anterior con Zone.js?

    Angular 22 elimina la dependencia de Zone.js y usa Signals declarativos. En lugar de interceptar microtasks para detectar cambios, los Signals notifican solo a sus lectores cuando cambian, proporcionando renders deterministas.

    ¿React 19 añade un primitivo signal?

    No: React 19 no introduce un primitivo signal. Usa el React Compiler para inferir memoización y optimizaciones, y añade use() para consumo de Promises/recursos en render.

    ¿Qué ventajas ofrecen los Signal-based Forms?

    Los Signal-based Forms permiten lectura síncrona del estado del formulario, reducen la necesidad de RxJS y evitan suscripciones filtradas. Mejoran trazabilidad y simplifican validaciones dependientes.

    ¿Cómo evaluar cuál elegir para mi equipo?

    Haz un piloto. Si necesitas control y trazabilidad estricta para sistemas críticos, Angular 22 es preferible. Si buscas mínima fricción y tu stack ya usa SSR/Next.js, React 19 reduce fricción de adopción.

    ¿Es migración a Signals retrocompatible?

    Parcialmente. Angular ofrece utilidades como toSignal() / toObservable() para migraciones incrementales, pero adaptar formularios y patrones RxJS puede requerir refactor. React 19 suele permitir migración más suave gracias al compilador.

  • Errores comunes al migrar a React Server Components en producción

    Errores comunes al migrar a React Server Components en producción

    React Server Components en producción: errores que nadie te cuenta

    Tiempo estimado de lectura: 5 min

    • Fronteras claras: mezclar datos pesados del servidor con Client Components provoca serialización y payloads enormes.
    • No convertir todo a client: usar “use client” globalmente anula los beneficios de RSC y regresa a una SPA pesada.
    • Latencia y caching: llamadas secuenciales y caché agresiva generan TTFB alto y fugas de datos entre usuarios.
    • Audita dependencias: muchas librerías no están preparadas para ejecución en server; lazy-load o wrappers client son necesarios.

    Introducción

    React Server Components en producción: errores que nadie te cuenta. Lo digo sin rodeos: los tutoriales y demos no te preparan para operarlos en tráfico real. En ese salto es donde aparecen fugas de datos, payloads monstruosos y cuellos de botella invisibles que desarman la promesa de “menos JS, mejor rendimiento”.

    Este artículo enumera los fallos concretos que verás en proyectos reales, aporta soluciones técnicas y define cuándo NO migrar a RSC. Incluye referencias y enlaces oficiales para que puedas profundizar: Suspense y caching en Next.js.

    Resumen rápido (lectores con prisa)

    Qué es: Patrón que permite renderizar parte de la UI en el servidor y enviar un árbol serializado al cliente.

    Cuándo usarlo: cuando puedas controlar la frontera server/client, minimizar datos pasados al cliente y beneficiarte de menos JS inicial.

    Por qué importa: mejora rendimiento y seguridad si se adopta con disciplina en serialización, caché y orquestación de datos.

    Cómo funciona: Server Components pueden acceder a recursos de servidor; Client Components se hidratan en cliente y deben recibir solo datos mínimos.

    1. React Server Components en producción: la frontera que rompe todo

    El error raíz es conceptual: tratar la frontera server/client como una línea estética en lugar de una decisión arquitectónica. Un Server Component puede acceder a la BD y luego pasar objetos enormes como props a un Client Component. Eso obliga a React a serializar todo en el HTML/JSON de respuesta. Resultado: la reducción del bundle se convierte en megabytes de payload.

    Ejemplo típico (malo)

    • Server Component hace SELECT * FROM orders WHERE user_id = ? y pasa todos los registros a <OrdersTable use client />.
    • El navegador recibe un payload serializado de decenas de MB.

    Solución: procesar, paginar y resumir en el servidor. Pasa al cliente solo el minimum viable (IDs, count, primeros N items) y provee endpoints client-side para cargar la página de datos al interactuar.

    2. El pánico del “use client” y la regresión a SPA

    Cuando algo falla (proveedores, librerías de UI, hooks), el atajo más común es colocar "use client" en el layout. Eso convierte todo el árbol en Client Components y anula el beneficio de RSC: vuelves a una SPA grande, con mayor complejidad y sin reducción de JS.

    Patrón correcto:

    • Mantén providers y estado en componentes hoja que realmente necesitan interactividad.
    • Diseña la composición para que los Client Components reciban props mínimos y, si requieren datos pesados, llamen a endpoints específicos (fetch desde cliente) o utilicen streaming.

    3. Waterfalls invisibles: el backend secuencial que mata TTFB

    Código like-this en Server Component:

    const user = await getUser(id);
    const prefs = await getPrefs(user.configId);
    const orders = await getOrders(user.id);
    

    Eso es secuencial: suma latencias. Aunque ocurre en servidor, el usuario espera. Paraleleza con Promise.all cuando no hay dependencia, y usa Suspense para streaming progresivo cuando sí hay dependencias parciales.

    Patrón secuencial y solución

    • Identifica llamadas independientes y ejecútalas en paralelo.
    • Usa streaming y Suspense para mostrar partes de la vista cuando están listas.
    • Mide TTFB en staging bajo carga para detectar waterfalls invisibles.

    4. Caché agresiva = fuga de datos entre usuarios

    Next.js y otros frameworks aplican caching por defecto en render server. Si renderizas una ruta con datos privados y no marcas la petición como dinámica, puedes cachear la vista de un usuario y servirla a otro. Es real y está pasando en producción.

    Contramedidas:

    • Para datos privados usa { cache: 'no-store' } en fetch o llama a APIs que leen cookies()/headers() (esto fuerza render dinámico en Next.js).
    • Revisa la documentación de caché de Next.js: caching en Next.js.
    • Considera políticas CDN más conservadoras para rutas autenticadas.

    5. Integraciones de terceros que no están listas para server execution

    Muchas librerías npm asumen un entorno DOM. Al ejecutar en server, aparecen errores en build o comportamiento inesperado. Resultado: el equipo marca "use client" masivo y pierde las ventajas. Revisa dependencias: algunas requieren reemplazo o lazy-loading estricto.

    Táctica práctica:

    • Audita las dependencias con npm ls y pruebas de build en CI que marquen dónde fallan.
    • Si una librería solo se usa en un widget, envuélvela en un Client Component lazy-loaded.

    Cuándo NO usar React Server Components

    No migres a RSC si tu producto encaja en alguno de estos casos:

    • Aplicaciones offline-first o PWAs que deben funcionar sin servidor.
    • Interfaces de hiper-interactividad: editores gráficos, juegos, vídeo en tiempo real o UIs con WebSockets a alta frecuencia.
    • Bases de código legacy sin presupuesto de reescritura: migrar Redux heavy/class components = reescritura, no refactor.

    Checklist práctico antes de migrar a producción

    1. Delimita claramente boundaries: quién corre en server y qué mínima data pasa al cliente.
    2. Añade tests de integración que simulen carga y validen payloads.
    3. Forza políticas de cache por ruta (privada vs pública).
    4. Instrumenta logs de tamaño de respuesta y tokenización/serialización.
    5. Adopta streaming/Suspense para vistas complejas; usa Promise.all para llamadas paralelas.
    6. Audita dependencias y evita convertir el layout en client por comodidad.

    Conclusión: RSC exige disciplina, no solo adopción

    React Server Components entregan ventajas claras (menos JS inicial, mayor seguridad para secretos, mejor SEO). Pero funcionan en producción solo si el equipo re-aprende backend: serialización, caching, latencia y orquestación de datos. La migración exitosa no es técnica aislada; es un cambio de modelo mental: pasar de “componentes” a “árboles de dependencias de red”. Si no estás dispuesto a trazar fronteras con rigor, no migres: estarás complicando tu arquitectura sin ganar sus beneficios.

    FAQ

    ¿Qué es exactamente un React Server Component?

    Un React Server Component se renderiza en el servidor y puede acceder a recursos del backend. No se hidrata en el cliente como un Client Component y se envía serializado al navegador.

    ¿Cuándo debo evitar migrar a RSC?

    Evita migrar si necesitas soporte offline completo, tienes UIs de hiper-interactividad (editores, juegos, video en tiempo real) o una base de código legacy sin presupuesto para reescritura.

    ¿Cómo evito pasar payloads gigantes al cliente?

    No pases objetos completos como props. Resumir, paginar y enviar solo lo mínimo necesario (IDs, count, primeros N items). Usa endpoints client-side para cargar datos adicionales bajo demanda.

    ¿Qué problemas de caché debo vigilar en Next.js?

    Cuidado con el render estático por defecto: rutas con datos privados pueden quedar cacheadas. Para datos privados usa { cache: 'no-store' } en fetch o APIs que lean cookies()/headers() para forzar render dinámico.

    ¿Cómo detectar y arreglar waterfalls en Server Components?

    Mide TTFB en staging bajo carga, revisa llamadas secuenciales en Server Components y paraleliza con Promise.all cuando sea posible. Usa streaming y Suspense para render progresivo.

    ¿Qué hago con librerías que fallan en server?

    Audita dependencias con npm ls, añade pruebas de build en CI y envuelve las librerías problemáticas en Client Components lazy-loaded o busca alternativas compatibles con server execution.