Estados de carga compuestos: lo que un booleano nunca supo decir
Cargando es una respuesta a dos preguntas distintas que un solo booleano aplasta en una: tengo datos que mostrar y hay una petición en vuelo. Separarlas en dos regiones ortogonales permite expresar la primera carga, el refresco silencioso sobre datos ya visibles, el reintento en segundo plano y el error parcial que deja información válida en pantalla. Esta lección modela esa matriz con estados paralelos, deriva de ella la interfaz sin condicionales inventados y explica por qué la degradación parcial es un requisito de producto, no un accidente.
Pregúntale a un booleano de carga qué está pasando y solo sabe decir sí o no. Pero la pantalla necesita distinguir al menos cuatro situaciones que ese sí engloba: no tengo nada y estoy pidiéndolo, tengo datos viejos y estoy refrescando, tengo datos y el refresco acaba de fallar, y no tengo nada y ya me rendí. Las cuatro comparten un valor verdadero en la misma variable y exigen interfaces completamente distintas —esqueleto, indicador discreto, aviso sobre contenido válido, pantalla de error—. La solución no es añadir un segundo booleano, porque eso multiplica las combinaciones imposibles; es reconocer que había dos preguntas independientes y darle a cada una su propia región.
- Descomponer el estado de carga en dos preguntas ortogonales: qué tengo y qué está en vuelo.
- Modelar esa matriz con regiones paralelas en lugar de banderas correlacionadas.
- Expresar el error parcial: un fallo nuevo que convive con datos válidos en pantalla.
- Derivar la interfaz de la combinación de regiones sin inventar condicionales intermedios.
Las dos preguntas que el booleano aplasta
El origen del problema es que una sola variable intenta responder a dos cosas que varían con independencia. La primera es sobre posesión: hay algo que pintar. La segunda es sobre actividad: hay trabajo en curso hacia el servidor. Ninguna determina a la otra, y por eso su producto tiene cuatro casillas legítimas y todas ocurren a diario.
| Tengo datos | Hay petición en vuelo | Situación | Interfaz correcta |
|---|---|---|---|
| no | sí | primera carga | esqueleto o marcador de posición |
| sí | sí | refresco en segundo plano | indicador discreto sobre el contenido |
| sí | no, y falló | error parcial | aviso junto a datos aún válidos |
| no | no, y falló | fallo total | pantalla de error con acción |
| sí | no | reposo con datos frescos | contenido limpio |
| no | no | vacío legítimo | estado vacío con llamada a la acción |
Primera carga
No hay nada que enseñar y la petición está en vuelo. Es el único caso donde el esqueleto es correcto, y es el que todos los demás acaban usurpando cuando el modelo no sabe distinguirlos.
Refresco silencioso
Hay contenido válido y se está actualizando. Tapar la pantalla aquí es un error de producto: el usuario pierde su lectura y su posición por una operación que quizá ni pidió.
Error parcial
El contenido sigue siendo válido y el último intento de actualizarlo falló. Merece un aviso discreto con acción, nunca una pantalla de error que borre información correcta.
Vacío frente a fallo
No hay datos porque no los hay, o porque no se pudieron traer. Son dos pantallas distintas y confundirlas hace que el usuario reintente donde no había nada que reintentar.
Seis casillas reales, y ningún par de booleanos las cubre sin permitir además combinaciones sin sentido. El intento clásico de resolverlo con tres banderas —cargando, refrescando y error— genera ocho combinaciones de las cuales la mitad son contradictorias, y esa mitad es precisamente donde viven los fallos de interfaz que solo se reproducen en redes lentas: el esqueleto que aparece sobre datos ya cargados, el error que sigue visible mientras el reintento tuvo éxito, el indicador de refresco eterno porque nadie lo apagó en una rama.
Que las dos preguntas sean independientes no es una invención de los statecharts: cualquier cache de datos moderna expone por separado si hay datos y si hay una petición en vuelo, precisamente porque el producto de ambas es lo que la interfaz necesita. Si usas esa librería, gran parte de esta matriz te llega resuelta; el trabajo de la máquina es coordinarla con el resto del flujo, no recalcularla. La lección sigue importando aunque no escribas el grafo, porque el vocabulario es el mismo.
Dos regiones ortogonales
Las regiones paralelas expresan exactamente esta estructura: dos dimensiones activas a la vez, cada una con sus propios estados y transiciones, sin combinaciones ilegales posibles porque ninguna región puede estar en dos posiciones ni en ninguna.
import { setup, assign } from 'xstate'
export const panel = setup({
types: { context: {} as { items: Item[]; error: unknown; obtenidoEn: number } },
}).createMachine({
type: 'parallel',
context: { items: [], error: null, obtenidoEn: 0 },
states: {
datos: {
initial: 'vacio',
states: {
vacio: {},
presente: {},
},
on: {
LLEGARON: {
target: '.presente',
actions: assign({ items: ({ event }) => event.items, obtenidoEn: () => Date.now() }),
},
},
},
red: {
initial: 'ocioso',
states: {
ocioso: { on: { PEDIR: 'trabajando' } },
trabajando: {
invoke: {
src: 'leerItems',
onDone: { target: 'ocioso', actions: 'emitirLlegaron' },
onError: { target: 'fallando', actions: assign({ error: ({ event }) => event.error }) },
},
},
fallando: { on: { PEDIR: 'trabajando' } },
},
},
},
})
Lo relevante no es la sintaxis sino lo que se vuelve imposible. La región datos no puede volver a vacio porque una petición falle: nadie escribió esa transición, así que un error jamás borra la pantalla. La región red no puede estar simultáneamente trabajando y fallando, así que el indicador de refresco y el aviso de error nunca aparecen juntos por accidente. Y el evento que puebla los datos es independiente del que gobierna la red, de modo que un dato que llegue por otra vía —un mensaje del servidor, una actualización optimista, una hidratación— entra por la misma puerta sin tocar la región de actividad.
stateDiagram-v2
state panel {
state datos {
[*] --> vacio
vacio --> presente: llegan items
presente --> presente: llegan items nuevos
}
--
state red {
[*] --> ocioso
ocioso --> trabajando: PEDIR
trabajando --> ocioso: resultado
trabajando --> fallando: error
fallando --> trabajando: PEDIR
}
}Si tu producto distingue entre dato fresco y dato aceptablemente viejo —mostrar un aviso cuando la información tiene más de cinco minutos, por ejemplo—, esa es una tercera pregunta ortogonal y merece su propia región con una transición temporizada, no un cálculo esparcido por los componentes. El criterio para abrir región nueva es siempre el mismo: si la respuesta a esta pregunta puede cambiar sin que cambien las otras, es una dimensión aparte.
El error parcial: datos viejos con un fallo nuevo
La casilla más valiosa de la matriz es la tercera, y es la que ningún booleano supo nunca expresar: tengo datos válidos en pantalla y el último intento de actualizarlos falló. Un modelo de un solo eje obliga a elegir entre dos mentiras: mostrar el error y esconder información correcta que el usuario estaba leyendo, o esconder el error y hacerle creer que lo que ve está al día. Con regiones separadas no hay que elegir, porque el fallo pertenece a la región de la red y los datos siguen en la suya.
export function vistaDe(snapshot: Snapshot) {
const conDatos = snapshot.matches({ datos: 'presente' })
const trabajando = snapshot.matches({ red: 'trabajando' })
const falla = snapshot.matches({ red: 'fallando' })
if (!conDatos && trabajando) return 'esqueleto'
if (!conDatos && falla) return 'errorTotal'
if (!conDatos) return 'vacio'
if (trabajando) return 'refrescando'
if (falla) return 'datosConAviso'
return 'limpio'
}
Esa función es un derivado puro, no un estado: no guarda nada, se recalcula y traduce la posición del grafo al vocabulario de la interfaz. Ponerla en un solo sitio evita que cada componente invente su propia mezcla de condiciones, y su forma de escalera hace evidente el orden de precedencia —primero la posesión, después la actividad— que en condicionales dispersos siempre acaba contradiciéndose entre pantallas.
La degradación parcial se generaliza cuando el panel agrega varias fuentes. Un tablero con tres secciones no tiene un estado de carga, tiene tres, y aplastarlos en uno solo produce el peor de los dos mundos: o la pantalla entera espera a la sección más lenta, o un fallo aislado tumba secciones que habían cargado bien. Modelar cada sección como una región propia —o como un actor hijo con su propia máquina de dos regiones— permite exactamente lo que el producto quiere: cada parte se muestra en cuanto puede, cada fallo se contiene donde ocurrió y la pantalla nunca queda en blanco por culpa de un endpoint caído.
La tentación al implementar esto es guardar el resultado de la vista en el contexto para no recalcularlo. Es el mismo error de siempre con otra ropa: en cuanto ese campo existe, hay dos fuentes de verdad y una de ellas se quedará atrás en alguna rama. La combinación de regiones ya contiene toda la información; la función que la traduce debe ser pura, barata y ejecutarse en el momento de pintar. Si te preocupa el coste, memoriza en el consumidor, no en el modelo.
Todo este nivel converge en la lección que el estado de carga enseña mejor que ningún otro ejemplo: el número de valores que un modelo admite decide qué situaciones el sistema es capaz de pensar. Un booleano no es una simplificación inocente de la realidad, es una decisión sobre qué distinciones dejan de existir, y las distinciones que el modelo no puede hacer son exactamente las que el equipo no podrá discutir después. Cuando la única variable disponible es cargando sí o no, el debate sobre si el refresco debe tapar el contenido no se resuelve mal: no se puede ni plantear, porque el vocabulario no tiene palabra para refrescar teniendo datos. Las regiones paralelas devuelven ese vocabulario, y con él una capacidad que parece de diseño de interfaz pero es de modelado: la de sostener la contradicción aparente de mostrar información válida y admitir a la vez que no pudiste actualizarla. Los sistemas honestos son los que pueden decir eso, y solo pueden decirlo si su modelo tiene sitio para la conjunción de un dato bueno y una operación fallida. Por eso la pregunta correcta ante cualquier bandera no es si es verdadera o falsa, sino cuántas preguntas independientes está intentando responder a la vez; cada respuesta que comprimes en un mismo eje es una situación real que tu producto tendrá que fingir que no existe. Modelar es, ante todo, negarse a esa ficción.
- Toma una pantalla real y clasifica sus situaciones en las seis casillas de la tabla; anota cuáles hoy no puedes distinguir.
- Reescribe su modelo con dos regiones paralelas, una para la posesión de datos y otra para la actividad de red.
- Comprueba que un fallo de refresco ya no puede vaciar la pantalla porque ninguna transición lo permite.
- Escribe la función pura que traduce la combinación de regiones a un nombre de vista y colócala en un único sitio.
- Provoca el error parcial con la red degradada y diseña qué ve el usuario: datos válidos, aviso discreto y acción para reintentar.
- Divide un panel con tres fuentes en tres regiones o actores y verifica que un fallo aislado no impide que las otras dos se muestren.