Muchos actores a la vez: cientos de máquinas hijas
Una máquina por fila es un patrón elegante que puede convertirse en mil actores vivos sin que nadie lo haya decidido conscientemente. Esta lección desglosa qué ocupa realmente un actor en memoria —definición compartida frente a instancia propia, snapshot, buzón, observadores y cierres retenidos—, construye un presupuesto por fila, compara tres arquitecturas para una lista larga, enumera las fugas típicas del ciclo de vida y propone un método de medición basado en instantáneas del montículo y en el recuento de actores vivos.
La idea de dar a cada fila su propia máquina es genuinamente buena: encapsula el ciclo de vida de un elemento, permite que una fila esté guardando mientras otra está editando y otra fallando, y elimina de un plumazo los mapas de identificadores a banderas que suelen infestar las listas editables. La idea se vuelve peligrosa cuando el número de filas deja de ser una docena y pasa a ser lo que devuelva el servidor. Mil filas son mil instancias con su snapshot, su buzón, su conjunto de observadores y su cierre reteniendo datos, más mil suscripciones desde la interfaz, más mil entradas en la estructura del padre que las guarda. Ninguna de esas cosas es cara por separado y todas juntas sí lo son. Antes de decidir si el patrón escala hay que saber contar lo que ocupa, y contar exige saber primero qué se comparte y qué se duplica.
- Distinguir qué parte de un actor se comparte con la definición y qué parte se duplica en cada instancia.
- Construir un presupuesto de memoria por fila y proyectarlo al tamaño real de tus listas.
- Comparar las tres arquitecturas posibles para una lista larga y elegir con criterio explícito.
- Medir actores vivos y memoria retenida con instantáneas del montículo y recuento instrumentado.
Qué se comparte y qué se duplica
La confusión más común consiste en creer que cada actor lleva consigo una copia de la máquina. No es así: la definición —los nodos, las transiciones indexadas, las implementaciones de acciones y guardas— se construye una vez y todas las instancias apuntan a ella. Lo que se duplica es mucho más modesto y también más silencioso.
flowchart TD A[definicion de la maquina] -->|compartida por todos| B[actor fila 1] A --> C[actor fila 2] A --> D[actor fila n] B --> B1[snapshot con valor y contexto] B --> B2[buzon de eventos pendientes] B --> B3[conjunto de observadores] B --> B4[referencias a hijos y temporizadores]
Cada instancia mantiene cuatro cosas propias. La primera es el snapshot vigente, con el valor finito y el objeto de contexto: pequeño si el contexto guarda escalares, arbitrariamente grande si guarda la fila entera, la respuesta original del servidor y una copia previa para poder deshacer. La segunda es el buzón, normalmente vacío pero con la estructura reservada. La tercera es el conjunto de observadores, que crece con cada componente suscrito a esa fila. La cuarta, y la más traicionera, son las referencias vivas: temporizadores pendientes, actores invocados y, sobre todo, los cierres creados por las implementaciones que capturan variables externas.
El sobrecoste estructural de un actor se mide en cientos de bytes, no en kilobytes, así que rara vez es el problema. El problema es lo que decides guardar dentro. Un contexto por fila que retenga el objeto original de la respuesta, la versión editada y una copia para deshacer triplica el peso de los datos que ya tenías, y esa triplicación se multiplica por el número de filas. La regla que evita el desastre es guardar en el contexto de un actor hijo lo mínimo que ese actor necesita para decidir, no una fotografía completa del dominio por si acaso.
El presupuesto por fila
Con esa descomposición se puede hacer una estimación honesta, entendida siempre como orden de magnitud sujeto a medición. La utilidad de la tabla no está en las cifras exactas sino en la proporción entre las filas: muestra dónde mirar cuando el montículo crece.
| Componente por actor | Orden de magnitud | Escala con |
|---|---|---|
| Estructura del actor y su snapshot vacío | cientos de bytes | número de actores |
| Contexto con escalares y un identificador | cientos de bytes | número de actores |
| Contexto que retiene el objeto de dominio | tamaño del objeto | número de actores por tamaño del dato |
| Entrada en el mapa o array del padre | decenas de bytes | número de actores |
| Suscripción desde la interfaz | decenas de bytes más el cierre | número de suscriptores |
| Temporizador o actor invocado por fila | variable, y retiene su cierre | número de actores activos |
Proyectada a mil filas con contexto ligero, la factura estructural queda en el orden de un megabyte largo: perfectamente asumible en una aplicación de escritorio y discutible en un dispositivo modesto. Proyectada a mil filas cuyo contexto retiene la respuesta completa del servidor más dos copias, la misma tabla arroja decenas de megabytes y una presión de recolección que se nota en forma de pausas. La conclusión operativa es que el patrón de un actor por fila no falla por el número de actores, falla por lo que cada actor decidió recordar.
Tres arquitecturas para una lista larga
Elegir entre ellas es la decisión estructural de esta lección, y las tres son defendibles según el caso.
Un actor por fila, siempre
Máxima claridad y aislamiento: cada fila tiene su ciclo de vida y su error. Coste lineal en número de filas. Correcto hasta el orden de las decenas o pocos cientos de elementos visibles.
Un actor para toda la lista
Un solo actor con la colección en el contexto y el estado por elemento como campo. Coste constante, pero cada cambio de un elemento produce un snapshot de la lista entera y obliga a un buen selector.
Actor perezoso bajo demanda
La fila vive como dato plano y solo obtiene actor cuando entra en un ciclo de vida propio, al editarla o al enviarla. Coste proporcional a las filas activas, no a las existentes.
La tercera opción es la que mejor envejece en listas grandes y merece que se vea su forma, porque la clave está en crear y detener con simetría estricta.
import { setup, assign, stopChild } from 'xstate'
export const lista = setup({
types: {
context: {} as { filas: Fila[]; editores: Record<string, ActorRefEditor> },
events: {} as { type: 'EDITAR'; id: string } | { type: 'CERRAR'; id: string },
},
actors: { editor: editorDeFila },
}).createMachine({
context: { filas: [], editores: {} },
on: {
EDITAR: {
actions: assign(({ context, event, spawn }) => {
if (context.editores[event.id]) return {}
const fila = context.filas.find((f) => f.id === event.id)
const ref = spawn('editor', { id: `editor-${event.id}`, input: { fila } })
return { editores: { ...context.editores, [event.id]: ref } }
}),
},
CERRAR: {
actions: [
stopChild(({ event }) => `editor-${event.id}`),
assign(({ context, event }) => {
const resto = { ...context.editores }
delete resto[event.id]
return { editores: resto }
}),
],
},
},
})
Dos detalles de ese código son la diferencia entre un patrón sano y una fuga garantizada. El primero es la guarda de idempotencia al crear: sin ella, dos eventos de edición sobre la misma fila producen dos actores y el segundo sobrescribe la referencia del primero, que queda vivo y huérfano para siempre. El segundo es que detener el hijo y borrarlo del contexto son dos acciones distintas y ambas son obligatorias: detener sin borrar deja una referencia muerta que sigue reteniendo memoria, y borrar sin detener deja un actor corriendo al que ya nadie puede alcanzar.
Una lista virtualizada solo monta los componentes visibles, y de ahí se salta con facilidad a la conclusión de que también reduce los actores. No es cierto salvo que se haya diseñado así: si los actores viven en el contexto del padre, siguen existiendo los mil aunque solo se pinten veinte. La virtualización ahorra nodos del documento y renders, que suele ser lo que duele primero, pero la memoria del sistema de actores es independiente de lo que esté a la vista y hay que gobernarla con su propia política de creación y parada.
Medir en vez de estimar
Toda la aritmética anterior es una hipótesis hasta que se contrasta, y contrastarla es más fácil de lo que parece porque el sistema de actores es observable por construcción. Un contador basado en el flujo de inspección da la cifra más importante de todas: cuántos actores hay vivos ahora mismo y si esa cifra vuelve a bajar cuando debería.
import { createActor } from 'xstate'
let vivos = 0
const actor = createActor(maquina, {
inspect: (ev) => {
if (ev.type === '@xstate.actor') vivos++
if (ev.type === '@xstate.snapshot' && ev.snapshot.status === 'done') vivos--
},
})
setInterval(() => console.log('actores vivos', vivos), 2000)
Ese número, observado durante un guion que abre y cierra editores repetidamente, responde a la única pregunta que importa sobre fugas: si tras cerrar todos los editores la cuenta no regresa a su valor inicial, hay actores huérfanos y ninguna medición de memoria hará falta para saber que el problema está en la simetría entre creación y parada. La instantánea del montículo entra después, para el segundo diagnóstico: se toma una antes del guion, otra después, se comparan y se examinan los retenedores de los objetos que sobreviven. Ahí es donde aparecen los cierres capturando datos grandes y las suscripciones sin cancelar, que son las dos causas dominantes de crecimiento sostenido en este patrón.
Un valor absoluto de memoria dice poco porque depende del navegador, de la pestaña y del momento de la recolección. Lo que sí es concluyente es la forma de la curva a lo largo de un guion repetido diez veces: si tras cada ciclo completo de abrir y cerrar el montículo queda por encima del ciclo anterior, hay una fuga y su pendiente te dice cuánto retiene cada iteración. Esa medida diferencial es reproducible, comparable entre ramas y suficiente para decidir si un cambio mejoró algo.
La razón profunda por la que las listas de mil actores acaban mal casi nunca es la memoria, que es medible y acotable, sino la confusión de categorías que las produjo. Crear un actor por fila se siente como crear un objeto por fila, y no lo es: un objeto se declara, se usa y desaparece cuando nadie lo referencia, mientras que un actor es una entidad viva con identidad, buzón y capacidad de acción, que sigue existiendo aunque nadie lo mire y solo desaparece cuando alguien decide explícitamente detenerlo. Al recolector de basura le basta con que se pierdan las referencias; al sistema de actores no, porque un actor sin referencias sigue teniendo temporizadores armados, invocaciones en vuelo y la potestad de enviar mensajes a otros. Esa asimetría entre el modelo de memoria del lenguaje y el modelo de vida de los actores es la fuente real de las fugas de este nivel, y por eso la disciplina que las evita no es técnica sino conceptual: cada acto de creación debe tener escrito, en el mismo momento en que se escribe, quién lo detendrá y bajo qué condición. La pregunta que separa a quien maneja bien un sistema de actores de quien lo sufre no es cuántos actores caben en memoria, sino cuál es el ciclo de vida de cada uno y quién responde por él. Cuando esa pregunta se contesta antes de teclear spawn, la arquitectura se ordena sola y aparecen las respuestas correctas: filas que no tienen ciclo de vida propio no necesitan actor y viven mejor como datos planos; filas que sí lo tienen lo obtienen cuando entran en él y lo pierden cuando salen. La memoria, entonces, deja de ser un problema que se persigue con instantáneas del montículo y se convierte en una consecuencia aritmética de decisiones de diseño que ya tomaste con los ojos abiertos.
- Instrumenta el recuento de actores vivos y registra su evolución durante un guion que abra y cierre veinte editores.
- Verifica que el recuento regresa exactamente a su valor inicial; si no lo hace, localiza la creación que carece de parada simétrica.
- Toma dos instantáneas del montículo alrededor de ese guion y examina los retenedores de los objetos que sobrevivieron.
- Calcula el presupuesto por fila de tu caso real multiplicando el peso del contexto por el número máximo de filas esperado.
- Reescribe el patrón como actor perezoso bajo demanda y compara memoria retenida y número de actores vivos con la versión anterior.
- Elimina la guarda de idempotencia al crear, dispara dos veces el mismo evento y demuestra con el contador que acabas de fabricar un actor huérfano.