Migrar de v4 a v5: inventario de roturas y ruta segura
La v5 no es una actualización menor: renombra conceptos centrales, retira el generador de tipos, sustituye el segundo argumento de createMachine por setup y cambia la interfaz del intérprete. Esta lección ordena ese desorden en dos mitades. Primero el inventario honesto de lo que rompe, agrupado por naturaleza —renombres mecánicos, cambios de forma y desapariciones sin sustituto directo— para saber qué se arregla con buscar y reemplazar y qué exige rediseñar. Después la ruta: preparar la red de tests basada en el propio grafo, actualizar en bloque las dependencias emparejadas, aplicar los renombres, borrar el typegen, adoptar setup máquina a máquina y solo al final incorporar las capacidades nuevas. Cierra con la estrategia de coexistencia por alias de paquete y con la regla que evita el peor error de todos: migrar por árbol de actores y no por fichero.
Migrar de la v4 a la v5 es incómodo por una razón que conviene nombrar sin rodeos: no es una limpieza de API, es un cambio de modelo mental que arrastra consigo un cambio de nombres. El actor pasa a ser la unidad central y por eso services se llama actors; la declaración precede a la construcción y por eso el segundo argumento de createMachine cede su sitio a setup; la inferencia ocurre en el compilador y por eso el generador de tipos desaparece entero, con su CLI y sus ficheros. Quien intente migrar con buscar y reemplazar acertará en la mitad de los casos y se estrellará en la otra mitad, que es exactamente la peor forma de hacerlo, porque el proyecto queda a medio camino entre dos modelos que no encajan. Esta lección separa esas dos mitades desde el principio y propone una ruta que las aborda en el orden correcto, con la red puesta y sin pedirte que apagues los tipos por el camino.
- Distinguir los renombres mecánicos de los cambios de forma y de las desapariciones sin sustituto.
- Traducir el patrón
createMachinecon segundo argumento al patrónsetupsin perder comportamiento. - Ejecutar la ruta de migración en seis pasos, con los tests del grafo como red de seguridad.
- Aplicar la coexistencia por alias de paquete y migrar por árbol de actores en lugar de por fichero.
El inventario: tres clases de rotura
No todos los cambios cuestan lo mismo. Los primeros son renombres puros: el concepto es idéntico y solo cambia la palabra, de modo que un reemplazo cuidadoso basta.
| v4 | v5 | Naturaleza |
|---|---|---|
cond |
guard |
Renombre |
services |
actors |
Renombre |
interpret(...) |
createActor(...) |
Renombre |
machine.withConfig(...) |
machine.provide(...) |
Renombre |
actor.state |
actor.getSnapshot() |
Cambio de forma |
event.data en onDone |
event.output |
Cambio de forma |
data en estado final |
output |
Cambio de forma |
state.done |
snapshot.status === 'done' con 'active' | 'done' | 'error' | 'stopped' |
Cambio de forma |
machine.withContext(...) |
input más context: ({ input }) => ... |
Cambio de forma |
in: 'estado' |
stateIn('estado') |
Cambio de forma |
send(...) interno |
raise(...) o sendTo(...) |
Cambio de forma |
pure y choose |
enqueueActions |
Desaparición |
activities |
invoke con fromCallback |
Desaparición |
escalate |
error del actor o sendParent |
Desaparición |
tsTypes y la CLI de typegen |
setup |
Desaparición |
La segunda clase, los cambios de forma, exige leer cada punto: no basta con cambiar la palabra porque también cambia la estructura del dato o el momento en que se obtiene. La tercera clase, las desapariciones, obliga a rediseñar el fragmento afectado; son pocas, pero son las que consumen el tiempo real de la migración.
xstate y sus integraciones —@xstate/react, @xstate/vue, @xstate/svelte, @xstate/store— van versionadas en pareja y no admiten mezcla: una máquina de v5 no funciona con un useMachine de v4. Actualiza todas a la vez o el proyecto no arrancará. En React, además, cambian dos nombres: useInterpret pasa a llamarse useActorRef, y useActor cambia de semántica, ya que ahora recibe una lógica de actor y no una referencia; para observar una referencia existente sin re-renderizar de más, la respuesta sigue siendo useSelector.
La traducción central: del segundo argumento a setup
El cambio que aparece en cada fichero es el mismo, y una vez visto un par de veces se hace mecánico. El primer argumento de la v4 pierde schema y tsTypes; el segundo argumento se disuelve dentro de setup repartido en sus casilleros; y services se convierte en actors.
// ANTES: v4
const maquina = createMachine(
{
schema: { context: {} as Ctx, events: {} as Ev },
tsTypes: {} as import('./maquina.typegen').Typegen0,
initial: 'inactivo',
states: {
inactivo: { on: { IR: { target: 'cargando', cond: 'puedeCargar' } } },
cargando: {
invoke: { src: 'traer', onDone: { actions: 'guardar' }, onError: 'fallo' },
},
fallo: {},
},
},
{
actions: { guardar: assign({ datos: (_, e) => e.data }) },
guards: { puedeCargar: (c) => c.permitido },
services: { traer: (c) => fetch(`/api/${c.id}`).then((r) => r.json()) },
},
)
// DESPUES: v5
const maquina = setup({
types: { context: {} as Ctx, events: {} as Ev },
actions: { guardar: assign({ datos: ({ event }) => event.output }) },
guards: { puedeCargar: ({ context }) => context.permitido },
actors: {
traer: fromPromise(async ({ input }: { input: { id: string } }) => {
const r = await fetch(`/api/${input.id}`)
return r.json()
}),
},
}).createMachine({
initial: 'inactivo',
states: {
inactivo: { on: { IR: { target: 'cargando', guard: 'puedeCargar' } } },
cargando: {
invoke: {
src: 'traer',
input: ({ context }) => ({ id: context.id }),
onDone: { actions: 'guardar' },
onError: 'fallo',
},
},
fallo: {},
},
})
Fíjate en el detalle que más sorprende al migrar: el servicio de la v4 leía el context directamente, mientras que el actor de la v5 recibe un input explícito. No es burocracia; es lo que permite que el actor sea reutilizable fuera de esta máquina y lo que hace verificable el contrato de entrada, tal como vimos en la lección anterior. Si te encuentras escribiendo un input que copia el context entero, casi siempre significa que el actor está haciendo demasiado.
createMachine sigue aceptando un objeto de implementaciones como segundo argumento en la v5, de modo que buena parte del código antiguo compilará tras los renombres. Es una trampa cómoda: funciona, pero no infiere, así que heredas la máquina sin tipos y sin el generador que en la v4 los suplía, es decir, en la peor de las dos épocas. Trátalo como un escalón intermedio legítimo para que la aplicación vuelva a arrancar cuanto antes, y no como un destino. Toda máquina que se quede ahí es una máquina que ha pagado el coste de migrar sin cobrar el beneficio.
La ruta en seis pasos y el orden que la hace segura
Antes de tocar una línea, pon la red. Los tests basados en el modelo son ideales aquí porque se derivan del propio grafo: si generas los caminos con @xstate/graph sobre la máquina de v4 y guardas las secuencias de eventos y los estados esperados, tendrás un oráculo que sobrevive a la migración y que detecta cualquier cambio de comportamiento que los renombres hayan introducido sin querer.
flowchart TD P0[paso 0: red de tests desde el grafo] --> P1[paso 1: subir xstate y su integracion a la vez] P1 --> P2[paso 2: renombres mecanicos] P2 --> P3[paso 3: borrar tsTypes y la CLI de typegen] P3 --> P4[paso 4: adoptar setup maquina a maquina] P4 --> P5[paso 5: estrechar el error unknown] P5 --> P6[paso 6: adoptar lo nuevo: params y enqueueActions] style P0 fill:#a6e3a1,color:#11111b style P4 fill:#cba6f7,color:#11111b style P6 fill:#89b4fa,color:#11111b
El orden importa más que la velocidad. Los renombres van antes que setup porque son verificables uno a uno y dejan la aplicación arrancando. El borrado del typegen va antes que la adopción de setup porque mantener ambos a la vez produce errores contradictorios difíciles de leer. Y las capacidades nuevas —parámetros, enqueueActions, spawnChild, emit— van al final, porque mezclarlas con la migración impide saber si un fallo viene del cambio de versión o del rediseño que acabas de introducir.
Queda la pregunta táctica: cómo dividir el trabajo en un proyecto grande. La respuesta es un alias de paquete, que permite instalar ambas versiones a la vez y migrar por partes.
{
"dependencies": {
"xstate": "^5.0.0",
"xstate4": "npm:xstate@^4.38.0"
}
}
Con las dos instaladas, cada módulo importa la versión que le toca y la migración deja de ser un salto de todo o nada. Pero hay una restricción que decide cómo trocear: una máquina de v5 no puede invocar como actor a una máquina de v4, ni al revés, porque el protocolo del actor cambió. La unidad de migración, por tanto, no es el fichero sino el ÁRBOL DE ACTORES: una máquina y todos sus hijos, nietos y descendientes se migran juntos, y la frontera entre versiones solo puede pasar por donde ya hay una frontera de aplicación —un componente de React, un módulo de servicio—, nunca por dentro de una jerarquía de actores.
Una migración es la única ocasión del año en que vas a leer todas tus máquinas seguidas, así que úsala como auditoría. Los activities que hay que reescribir suelen ser suscripciones que encajan mejor como fromCallback o fromObservable. Los pure largos que hay que convertir a enqueueActions suelen revelar lógica que debería vivir en un guard. Y los escalate desaparecidos casi siempre señalan un error que nunca debió subir hasta el padre. Migrar sin revisar convierte deuda de la v4 en deuda de la v5 con acento nuevo.
Es tentador leer este inventario como una lista de decisiones arbitrarias que alguien tomó y que a ti te toca pagar. Vale la pena leerlo al revés, porque casi todas las roturas se derivan de dos correcciones conceptuales y no de un capricho de nomenclatura. La primera es que el actor pasó a ser la unidad central: de ahí sale que services se llame actors, que un servicio ya no lea el context de su padre sino que reciba un input explícito como cualquier actor autónomo, que escalate desaparezca porque un actor no escala hacia arriba sino que falla y su padre observa, y que interpret se llame createActor porque lo que se crea ya no es un intérprete de máquinas sino un actor que resulta tener una máquina dentro. La segunda es que la declaración pasó a preceder a la construcción: de ahí sale setup, y de ahí sale que el generador de tipos se pudiera borrar entero en lugar de mejorarlo. Ninguna de las dos es una preferencia estética; ambas son la clase de corrección que un sistema solo puede hacer rompiendo su interfaz, porque el nombre viejo describía el modelo viejo con exactitud. Esta observación tiene una consecuencia práctica que te servirá mucho más allá de XState. Cuando midas el coste de una migración, mide también qué concepto se corrigió: si la respuesta es ninguno y solo cambiaron las palabras, tienes derecho a enfadarte y quizá a no migrar; si la respuesta es que el modelo mental mejoró, entonces el coste no es el precio de la nueva versión, es el precio aplazado de haber programado durante años contra un modelo que estaba un poco equivocado. La v5 pertenece de lleno al segundo caso, y por eso la ruta correcta no termina en el paso donde la aplicación vuelve a compilar, sino en el paso donde el código empieza a decir lo que la nueva versión entendió mejor.
- Inventaría tus máquinas y clasifícalas por árbol de actores, no por fichero. Marca dónde pasan las fronteras de aplicación que podrían separar versiones.
- Genera con
@xstate/graphlos caminos de una máquina de v4, guárdalos como oráculo y escribe el test que los recorre. Esa será tu red durante el resto del proceso. - Aplica solo los renombres mecánicos de la primera clase y comprueba que los tests siguen en verde antes de tocar nada más.
- Elige la máquina más pequeña, bórrale el
tsTypes, conviértela asetupy anota cuántos errores nuevos aparecen. Son los que el typegen te estaba ocultando. - Convierte un
serviceque leía elcontexten un actor coninputexplícito y valora si el actor resultante podría reutilizarse en otra máquina. - Localiza un
pure, unchoose, unactivityo unescalatey rediseña ese fragmento con las primitivas de la v5. Explica qué concepto de la v4 estaba forzando esa construcción.