Otros recursos con ciclo de vida: un solo esqueleto para todos
El reproductor no era un caso especial: era el representante visible de una familia entera. Una conexión persistente, una descarga larga, una cámara, un trabajador en segundo plano y una sesión de geolocalización comparten el mismo esqueleto de seis posiciones —sin adquirir, adquiriendo, listo, activo, degradado y liberado— porque todos son recursos externos que hay que pedir, sostener y devolver. Esta lección extrae ese esqueleto, lo instancia tres veces sobre dominios distintos, y precisa qué parte del patrón es invariante y qué parte cambia con el permiso, la revocación y la liberación de cada recurso.
Cuando alguien aprende el patrón del reproductor suele archivarlo como conocimiento sobre multimedia, y con eso pierde lo más valioso que había en él. El vídeo no es interesante por ser vídeo, es interesante por ser un recurso con ciclo de vida: algo que no existe hasta que lo pides, que tarda en estar disponible, que puede negarse, que consume memoria o batería o ancho de banda mientras lo sostienes, que puede degradarse sin desaparecer, y que hay que devolver de forma explícita porque nadie lo hará por ti. Esa descripción no menciona píxeles ni fotogramas, y describe igual de bien una conexión persistente, una descarga de un fichero grande, el acceso a la cámara del dispositivo, un trabajador en segundo plano o una suscripción a la posición del usuario. Todos comparten el mismo esqueleto porque todos plantean el mismo problema, y una vez lo reconoces, dejas de escribir cinco integraciones distintas y empiezas a instanciar una sola forma con cinco vocabularios.
- Extraer el esqueleto común de seis posiciones que comparte cualquier recurso con ciclo de vida.
- Instanciar ese esqueleto sobre una conexión persistente, una descarga y una cámara.
- Situar el permiso y la revocación como estados propios y no como excepciones del camino feliz.
- Garantizar la liberación del recurso en la salida del estado que lo sostiene, no en el desmontaje de la vista.
El esqueleto invariante
Las seis posiciones se repiten con una regularidad que da vértigo la primera vez que se comprueba. sin_adquirir es antes de pedir nada, y es el único estado sin coste. adquiriendo cubre la latencia inevitable entre pedir y tener, que en unos recursos es de milisegundos y en otros incluye un diálogo con el usuario. listo es tenerlo sin usarlo todavía, la posición que casi todo el mundo olvida y que separa la disponibilidad del consumo. activo es el uso efectivo, el único estado que produce el valor que motivó todo. degradado es seguir teniendo el recurso pero en condiciones peores, y es la posición que distingue un modelo maduro de uno binario. liberado es haber devuelto el recurso de forma deliberada, que no es lo mismo que no haberlo pedido nunca.
import { setup, fromCallback, assign } from 'xstate'
type Hecho =
| { type: 'ADQUIRIDO' }
| { type: 'DENEGADO'; motivo: string }
| { type: 'DEGRADADO'; motivo: string }
| { type: 'RECUPERADO' }
| { type: 'PERDIDO'; motivo: string }
| { type: 'COMPLETADO' }
export const recurso = setup({
types: {
context: {} as { motivo: string | null; degradaciones: number },
events: {} as Hecho | { type: 'PEDIR' } | { type: 'USAR' } | { type: 'SUSPENDER' } | { type: 'LIBERAR' },
},
actors: { fuente: fromCallback(() => () => {}) },
}).createMachine({
id: 'recurso',
initial: 'sin_adquirir',
context: { motivo: null, degradaciones: 0 },
states: {
sin_adquirir: { on: { PEDIR: 'adquiriendo' } },
adquiriendo: {
invoke: { id: 'fuente', src: 'fuente' },
on: {
ADQUIRIDO: 'listo',
DENEGADO: { target: 'liberado', actions: assign({ motivo: ({ event }) => event.motivo }) },
},
},
listo: {
invoke: { id: 'fuente', src: 'fuente' },
on: { USAR: 'activo', LIBERAR: 'liberado' },
},
activo: {
invoke: { id: 'fuente', src: 'fuente' },
on: {
SUSPENDER: 'listo',
COMPLETADO: 'liberado',
LIBERAR: 'liberado',
DEGRADADO: {
target: 'degradado',
actions: assign({
motivo: ({ event }) => event.motivo,
degradaciones: ({ context }) => context.degradaciones + 1,
}),
},
PERDIDO: { target: 'adquiriendo', actions: assign({ motivo: ({ event }) => event.motivo }) },
},
},
degradado: {
invoke: { id: 'fuente', src: 'fuente' },
on: { RECUPERADO: 'activo', PERDIDO: 'adquiriendo', LIBERAR: 'liberado' },
},
liberado: { on: { PEDIR: 'adquiriendo' } },
},
})
stateDiagram-v2 [*] --> sin_adquirir sin_adquirir --> adquiriendo: PEDIR adquiriendo --> listo: ADQUIRIDO adquiriendo --> liberado: DENEGADO listo --> activo: USAR activo --> listo: SUSPENDER activo --> degradado: DEGRADADO degradado --> activo: RECUPERADO activo --> adquiriendo: PERDIDO degradado --> adquiriendo: PERDIDO activo --> liberado: COMPLETADO liberado --> adquiriendo: PEDIR
La distinción entre tener el recurso y estar usándolo es la que casi todas las implementaciones caseras se saltan, y es la que decide el consumo real del dispositivo. Una cámara concedida pero sin capturar sigue encendiendo el testigo luminoso y gastando batería; una conexión establecida pero sin suscripciones sigue manteniendo el enlace vivo y consumiendo un descriptor en el servidor. Fundir ambas posiciones obliga a elegir entre dos males: liberar demasiado pronto y pagar el coste de readquirir en cada uso, o no liberar nunca y sostener un recurso ocioso durante horas. Con las dos posiciones separadas, la política de liberación se convierte en una transición temporizada desde listo y deja de ser una heurística escondida en un efecto.
Tres recursos, el mismo grafo
Lo que cambia entre dominios es el vocabulario, no la forma. La tabla siguiente traduce cada posición del esqueleto a tres recursos muy distintos, y la coincidencia estructural es exacta pese a que ninguno de los tres se parece a los otros en su API.
| Posición | Conexión persistente | Descarga larga | Cámara |
|---|---|---|---|
adquiriendo |
apertura del enlace y saludo inicial | petición y cabeceras de respuesta | diálogo de permiso del navegador |
listo |
enlace abierto sin suscripciones | flujo abierto sin escribir aún | pista concedida, sin renderizar |
activo |
mensajes fluyendo en ambos sentidos | fragmentos escribiéndose a disco | fotogramas llegando al lienzo |
degradado |
latencia alta o cola de reenvío | velocidad caída, reanudable por rango | resolución reducida por térmica |
PERDIDO |
cierre inesperado del enlace | interrupción de la conexión | pista revocada por el sistema |
liberado |
cierre limpio con código | fichero completo o cancelado | pistas detenidas y testigo apagado |
Vale la pena detenerse en las diferencias que sí importan, porque son las que impiden que el esqueleto sea una plantilla ciega. El permiso de la cámara puede denegarse de forma permanente, y esa negativa no admite reintento automático: hay que llevar al usuario a la configuración del navegador, así que DENEGADO merece un destino distinto del de la cancelación voluntaria. La descarga tiene progreso, que es una magnitud y por tanto vive en el contexto, y tiene además reanudación por rango, que convierte el reintento en una petición distinta de la original en lugar de en una repetición. La conexión persistente es la única de las tres cuyo PERDIDO es esperable en la operación normal y no un incidente, lo que justifica un tratamiento de reconexión mucho más elaborado.
Conexión persistente
Adquirir es abrir el enlace; degradarse es acumular cola; perder es un cierre inesperado que exige reconectar con espera creciente.
Descarga larga
El progreso va al contexto, no al estado. La reanudación por rango convierte el reintento en una petición parcial, no en una repetición.
Cámara o micrófono
El permiso es un estado con tres desenlaces, y la revocación puede llegar del sistema operativo sin que tu código haga nada.
Posición o sensores
La precisión degradada es el caso de libro de degradado: sigues teniendo el recurso, pero ya no vale lo mismo.
Liberar es parte del modelo, no del desmontaje
El error operativo más caro de esta familia no está en adquirir sino en devolver. La costumbre de liberar el recurso en el desmontaje de la vista funciona en el camino feliz y falla en todos los demás: la vista puede sobrevivir al recurso, el recurso puede sobrevivir a la vista, y una navegación interrumpida deja una cámara encendida o un enlace abierto que nadie recuerda. La regla correcta es colocar la liberación en la salida del estado que sostiene el recurso, o mejor aún, en la función de limpieza del actor invocado, porque entonces el intérprete la garantiza en todas las salidas, incluidas las que no habías previsto.
activo: {
invoke: {
id: 'pista',
src: fromCallback(({ input, sendBack }) => {
const stream = input.stream as MediaStream
const alTerminar = () => sendBack({ type: 'PERDIDO', motivo: 'pista revocada' })
stream.getTracks().forEach((t) => t.addEventListener('ended', alTerminar))
return () => {
stream.getTracks().forEach((t) => {
t.removeEventListener('ended', alTerminar)
t.stop()
})
}
}),
},
}
Un recurso puede desaparecer sin que tu código haga nada: el sistema operativo cede la cámara a otra aplicación, la red móvil corta el enlace, el navegador suspende la pestaña en segundo plano. Si tu grafo solo contempla liberaciones iniciadas por ti, esos casos te encuentran en un estado que ya no corresponde a la realidad, y la interfaz seguirá mostrando un recurso activo que no existe. Todo estado que sostiene un recurso necesita una arista de pérdida involuntaria, y esa arista debe llevar a un destino distinto del de la liberación voluntaria, porque la reacción correcta también es distinta: una pide reintentar, la otra no.
La razón profunda de que estos cinco dominios compartan grafo es que ninguno de ellos trata realmente sobre datos: todos tratan sobre custodia. Cuando tu programa adquiere una cámara, un enlace o un descriptor, contrae una obligación con un tercero que no puede verificarla —el sistema operativo no sabe si sigues necesitando la cámara, solo sabe que no la has soltado— y esa obligación tiene que ser recordada por alguien mientras dure. El lugar donde vive ese recuerdo es exactamente lo que decide si tu programa es correcto. Si vive en el ciclo de vida de un componente visual, has atado una obligación con el mundo exterior a un detalle de tu capa de presentación, y cualquier cambio de esa capa —una navegación, un renderizado condicional, un montaje doble en desarrollo— pone en riesgo una promesa que no tenía nada que ver con dibujar. Si vive en el estado explícito de una máquina, la obligación es visible, se puede inspeccionar, se puede probar y, sobre todo, la libera el intérprete en toda salida del estado, incluidas las que no anticipaste. De ahí se sigue el criterio general para cualquier recurso externo, y merece enunciarse como ley: la adquisición y la liberación deben estar en el mismo nivel de abstracción, y ese nivel es el estado que da sentido a tener el recurso, no la función que casualmente lo pidió primero. Cuando esa simetría se rompe —adquieres en un sitio y liberas en otro— lo que has construido no es una integración sino una fuga en potencia, y las fugas de recursos no se manifiestan como errores sino como degradaciones lentas que nadie sabe atribuir. Modelar el ciclo de vida es, en el fondo, llevar la contabilidad honesta de lo que le debes al sistema.
- Implementa el esqueleto genérico y comprueba en el inspector que las seis posiciones son alcanzables con la secuencia mínima de eventos.
- Instáncialo sobre una conexión persistente y traduce cada hecho del enlace al vocabulario del esqueleto sin inventar posiciones nuevas.
- Instáncialo sobre una descarga larga, coloca el progreso en el contexto y justifica por qué no merece estados propios.
- Instáncialo sobre la cámara y separa la denegación permanente de la cancelación voluntaria en dos destinos distintos.
- Provoca una revocación externa deteniendo la pista desde fuera de tu código y verifica que la máquina llega a la arista de pérdida involuntaria.
- Traslada la liberación desde el desmontaje de la vista a la limpieza del actor invocado y comprueba que una navegación abrupta ya no deja el recurso vivo.