wandres.dev
HMR · hot module replacement

HMR en frameworks: Fast Refresh y la preservación de estado

React con Fast Refresh y Solid con solid-refresh conservan el estado de los componentes al editar. Qué inyecta el plugin por debajo: registro de componentes, firmas de hooks y un accept automático que delega en el runtime del framework. Sus reglas y sus límites.

⏱ 18 min

Nunca escribes import.meta.hot en un componente de React o de Solid, y sin embargo editarlo conserva su estado. El plugin lo hace por ti: reescribe tu módulo para registrar cada componente, firmarlo e inyectar un accept que cede el control al runtime de refresco del framework. Entender esa transformación explica a la vez la magia y sus reglas: por qué importa la mayúscula, por qué las exportaciones mezcladas la rompen, por qué cambiar los hooks reinicia el estado.

🎯 Al terminar esta lección sabrás
  • Entender qué inyecta el plugin de React para lograr Fast Refresh.
  • Ver cómo Solid preserva estado sin re-render mediante solid-refresh.
  • Relacionar la magia del framework con los primitivos accept, dispose y data.
  • Conocer las reglas que un módulo debe cumplir para refrescar en caliente.

React Fast Refresh: registrar, firmar, aceptar

El plugin —@vitejs/plugin-react con Babel, o @vitejs/plugin-react-swc con SWC— transforma cada módulo en desarrollo. Por cada componente hace tres cosas. Lo registra en el runtime de refresco bajo un identificador estable con $RefreshReg$, para que pueda encontrar la instancia vieja y cambiarle la implementación. Calcula su firma con $RefreshSig$, capturando sus hooks —su orden e identidad— para saber si la forma de los hooks cambió. E inyecta un import.meta.hot.accept que, en vez de re-renderizar a mano, llama a performReactRefresh del runtime react-refresh.

// lo que el plugin inyecta, simplificado, alrededor de tu componente
import { performReactRefresh } from "virtual:react-refresh";

function Contador() { /* tu componente */ }

// registro con id estable para que el runtime encuentre la instancia
$RefreshReg$(Contador, "Contador");

if (import.meta.hot) {
  import.meta.hot.accept((nuevo) => {
    // no re-renderiza a mano: delega en el runtime de react-refresh
    performReactRefresh();
  });
}

El truco de fondo es que React re-monta usando las nuevas funciones de componente pero conserva el estado del fiber, indexado por el id de registro. El estado sobrevive porque a React se le dice, en efecto, mismo componente, nueva implementación, y por eso useState y useRef mantienen su valor. En desarrollo el plugin además inyecta un preámbulo en el HTML que define los globales $RefreshReg$ y $RefreshSig$ antes de que corra cualquier módulo.

El plugin viene en dos sabores que hacen lo mismo por caminos distintos. La variante con Babel da máxima compatibilidad y admite otros plugins de Babel; la variante con SWC, escrito en Rust, es notablemente más rápida en proyectos grandes a cambio de un ecosistema de plugins más pequeño. Ambos inyectan el mismo registro, la misma firma y el mismo accept; la diferencia es solo el motor que reescribe tu código, otro eco de la separación entre interfaz estable y motor reemplazable que atraviesa todo el track.

flowchart LR
src[Tu componente] --> plugin[Plugin react en dev]
plugin --> reg[Registra el componente]
plugin --> sig[Calcula la firma de hooks]
plugin --> acc[Inyecta accept que llama al runtime]
acc --> refresh[performReactRefresh preserva el estado]
style refresh fill:#a6e3a1,color:#11111b

De este mecanismo caen, como consecuencias directas, todas las reglas de Fast Refresh: los componentes deben ir en PascalCase y con nombre para que la transformación los detecte y registre; un módulo solo es límite de refresco si todos sus exports son componentes; y cambiar los hooks —añadir, quitar, reordenar— cambia la firma y reinicia el estado a propósito, porque conservarlo sería incorrecto.

Merece una nota qué cuenta como componente a ojos de la transformación: la heurística es un identificador en PascalCase que devuelve JSX. Lo que no encaja en ese molde —un hook, una función auxiliar, una constante— no se registra ni se firma, y su mera presencia entre los exports es lo que descalifica al módulo como límite de refresco. Además, toda esta reescritura ocurre solo en desarrollo: en el build el plugin no inyecta nada, los globales de refresco no existen y el import.meta.hot se elimina, de modo que tu componente de producción es exactamente el que escribiste, sin una línea de andamiaje.

Solid: preservar estado sin re-render

Solid no tiene DOM virtual ni re-render: la función de un componente corre una sola vez y cablea computaciones reactivas de grano fino. Por eso el modelo de Fast Refresh —re-montar conservando el estado— no aplica. solid-refresh, que usa vite-plugin-solid, toma otra ruta.

Envuelve cada componente en un ayudante de refresco que, al llegar una actualización, desecha la raíz reactiva anterior y la recrea bajo el mismo dueño, reejecutando el nuevo cuerpo del componente mientras transfiere las señales que puede identificar. En su modo granular por defecto reemplaza la implementación dentro de un envoltorio estable, de modo que la referencia que ve el padre no cambia; las señales locales se reinicializan, pero el estado elevado a stores o a señales de ámbito de módulo persiste a través del intercambio.

El resumen honesto: Solid preserva menos de forma automática dentro de un componente que React, porque reejecutar el cuerpo recrea las señales locales, y el idioma es mantener el estado duradero en stores o en createSignal a nivel de módulo, que sobreviven al ciclo dispose/data del módulo. El plugin sigue inyectando el mismo accept y dispose por debajo; lo que cambia es qué hace el runtime con ellos, ajustado al modelo de ejecución de cada framework.

La consecuencia práctica es un idioma distinto al de React. Allí tiendes a dejar el estado dentro del componente y confiar en que Fast Refresh lo conserve; en Solid, si un valor debe sobrevivir a ediciones agresivas, lo sacas a un store o a una señal de módulo desde el principio. No es una limitación que sortear, sino el reflejo fiel de cómo Solid ejecuta: el cuerpo del componente es código de configuración que corre una vez, y volver a correrlo es, por definición, empezar de cero salvo lo que vive fuera de él.

En la práctica, mantener el estado duradero fuera del componente es tan simple como declararlo en el ámbito del módulo:

// estado duradero fuera del componente: sobrevive al refresco
const [tema, setTema] = createSignal('oscuro');

function Panel() {
  // las senales locales de aqui se reinician; tema no
  const [abierto, setAbierto] = createSignal(false);
  return vista(tema, abierto);
}

Las dos estrategias se ven mejor una al lado de la otra: ante la misma edición, React re-monta preservando el fiber, mientras que Solid desecha y recrea la raíz reactiva, y cada uno conserva un tipo distinto de estado.

flowchart TD
edit[Editas un componente] --> react[React re-monta el fiber con la nueva funcion]
edit --> solid[Solid desecha y recrea la raiz reactiva]
react --> rstate[Conserva useState por identidad de fiber]
solid --> sstate[Conserva senales elevadas a store o modulo]
style rstate fill:#89b4fa,color:#11111b
style sstate fill:#a6e3a1,color:#11111b
ℹ️
El patrón se generaliza

El HMR de los componentes de un solo archivo de Vue, el de Svelte, el de Preact vía @prefresh/vite y las integraciones de stores como Pinia o Redux hacen todos lo mismo: un plugin o una transformación inyecta los cuatro primitivos y delega en un runtime que sabe intercambiar la implementación conservando la identidad. Los primitivos son universales; la política de preservación es específica de cada framework.

HMR más allá de los componentes: stores y datos

La preservación de estado no es exclusiva de los componentes. Un store global —el carrito, la sesión, el tema— también quiere sobrevivir a la edición de su propia definición, y las librerías de estado se integran con el HMR de Vite para lograrlo. El caso canónico es Pinia, que expone un ayudante para que el módulo del store se acepte a sí mismo y parchee la instancia viva con la nueva definición en lugar de recrearla.

// un store que se parchea en caliente en vez de recrearse
export const useCarrito = defineStore("carrito", { /* estado y acciones */ });

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useCarrito, import.meta.hot));
}

La idea clave, y la que generaliza a cualquier estado global, es que aquí preservar significa parchear, no intercambiar. Si al editar la definición del store recrearas la instancia, perderías su contenido —el carrito se vaciaría en cada guardado—. En cambio, el ayudante toma la instancia existente y le aplica la nueva forma: nuevas acciones, estado inicial para las claves que no existían, conservando los valores actuales de las que sí.

El patrón se repite por todo el stack de datos. Los routers reinstalan sus rutas sin perder la navegación actual, y las cachés como las de TanStack Query invalidan y refetchean sin tirar lo ya cargado. En cada caso, un plugin o una integración inyecta los primitivos de HMR y traduce lo genérico a lo específico: qué significa, para esta pieza concreta, seguir siendo la misma tras el cambio.

La moraleja transversal es que el HMR de estado global no es un caso especial, sino el mismo mecanismo con otro runtime al mando. Donde un componente delega su continuidad en el reconciliador, un store la delega en su librería y una caché de datos en su gestor de invalidación. En todos ellos tú solo eliges qué estado merece sobrevivir; la maquinaria de aceptar, limpiar y parchear ya está inyectada por debajo.

Las reglas, y por qué existen

Las reglas de Fast Refresh no son manías arbitrarias de un linter: cada una es consecuencia directa de cómo funciona el truco de preservación.

🧩

Solo componentes por archivo

Para que el módulo pueda ser un límite autoaceptante. Un export que no es componente rompe la firma y el límite, y fuerza la propagación hacia los importadores.

🔠

Nombrados y en mayúscula

Para que la transformación los detecte como componentes y los registre con un id estable con el que el runtime encuentre su instancia.

🪝

Hooks estables

La firma es el contrato. Cambiar el orden o la identidad de los hooks vuelve el estado viejo posiblemente sin sentido, así que se reinicia por diseño, no por bug.

🗄️

Estado duradero elevado

En Solid sobre todo: si quieres que un valor sobreviva a ediciones agresivas, sácalo del ámbito local volátil a un store o a una señal de módulo.

⚠️
Cuando Fast Refresh deja de preservar estado sin razón aparente

Casi siempre es una de tres causas, y cada una mapea a un mecanismo. Un export que no es componente coló en el archivo —rompe el límite—. Un componente exportado por defecto y anónimo —el runtime no puede darle un id estable—. O cambiaste los hooks —cambió la firma y el reinicio es intencional—. Reconocer cuál de las tres fue explica el comportamiento sin necesidad de suponer magia rota.

Los mismos primitivos, semánticas de continuidad opuestas

Los plugins de framework son el pago de todo el nivel: convierten el protocolo crudo de import.meta.hot en algo que nunca tienes que ver, y lo hacen codificando el modelo de ejecución del framework dentro de los primitivos accept, dispose, data e invalidate. React puede preservar estado porque su reconciliador ya separa la identidad de un componente de su implementación, así que Fast Refresh solo tiene que decirle mismo fiber, nueva función y el estado viaja con él. Solid no puede hacer eso —no hay reconciliador ni re-render—, así que solid-refresh preserva estado controlando en su lugar el ciclo de vida de la raíz reactiva, desechándola y recreándola mientras mantiene vivas las señales elevadas. Dos runtimes opuestos, los mismos cuatro primitivos, estrategias de preservación radicalmente distintas: esa es la lección más profunda del HMR. Los primitivos son un sustrato universal y agnóstico del framework; la inteligencia vive en el runtime que decide qué significa el mismo componente, actualizado para su propio modelo de cómputo. Por eso el HMR es un protocolo por capas y no una sola función: Vite aporta el grafo, la propagación y los primitivos; el plugin del framework aporta la semántica de la continuidad. Cuando por fin ves que editar un componente y conservar su estado es alcanzabilidad en el grafo, más un accept inyectado por el plugin, más un runtime que conoce el modelo de identidad de tu framework, la magia se disuelve en tres mecanismos legibles, y puedes depurar, extender o incluso escribir soporte de HMR para un framework nuevo, porque sabes con exactitud qué capa es dueña de qué responsabilidad.

⚔️ Desmonta la magia
  1. Edita el JSX de un componente de React y confirma que useState sobrevive; luego añade un hook y observa cómo el estado se reinicia. Explícalo por la firma.
  2. Añade una constante exportada a un archivo de componente y observa cómo se rompe el límite por exports mezclados; divídelo y restaura las actualizaciones calientes.
  3. En una app de Solid, edita un componente y observa qué estado sobrevive; mueve una señal al ámbito de módulo y compara el resultado.
  4. Integra acceptHMRUpdate en un store de Pinia y comprueba que editar sus acciones no vacía su estado.
  5. Lee el HTML o el fuente en desarrollo y localiza el preámbulo de refresco inyectado y el accept que generó el plugin.
  6. Explica en un párrafo por qué los mismos primitivos rinden preservaciones distintas en React y en Solid.