Cuando un booleano se vuelve cinco
La señal más nítida de que un problema pide más estructura que un useState no es la cantidad de estado, sino su correlación: el momento en que un booleano se convierte en cinco booleanos que en realidad son una sola variable con reglas. Esta lección diagnostica la explosión combinatoria de banderas correlacionadas, muestra la vigilancia manual que ensucia el render, recorre la escalera de remedios —del enum de status a la máquina— y exhibe el payoff: un render exhaustivo que deja de defenderse de estados que ya no puede representar.
Casi ninguna máquina de estado nace como máquina. Nace como un useState(false) inocente que resuelve un caso, al que se le suma otro booleano para el siguiente caso, y otro, hasta que un día descubres que tienes cinco banderas que nunca son de verdad independientes: cuando una es cierta, dos deben ser falsas, y ciertas combinaciones “no deberían pasar nunca” pero el tipo las permite. Ese es el momento diagnóstico del nivel. No es que tengas mucho estado; es que tienes varias variables que secretamente son una sola. Aprender a oír esa señal a tiempo es lo que evita tanto el bug del estado imposible como la sobreingeniería prematura.
- Diagnosticar la señal real: booleanos que se multiplican y quedan correlacionados, no la mera cantidad de estado.
- Ver la explosión combinatoria
2^ny por qué casi todas esas combinaciones son estados imposibles. - Recorrer la escalera de remedios: booleano, enum de status, unión discriminada, máquina.
- Reconocer el payoff de subir un peldaño: el render deja de vigilar correlaciones a mano.
Un booleano, dos, cinco: la explosión correlacionada
El caso arquetípico es la carga de datos. Empiezas con lo mínimo, un useState(false) para cargando. Luego quieres mostrar errores, así que añades error. Luego distinguir “aún no pedí nada” de “pedí y vino vacío”, así que añades exito y vacio. Y como el reintento debe verse distinto, reintentando. Cinco booleanos:
const [cargando, setCargando] = useState(false)
const [error, setError] = useState(false)
const [exito, setExito] = useState(false)
const [vacio, setVacio] = useState(false)
const [reintentando, setReintentando] = useState(false)
El problema no es que sean cinco. El problema es que no son independientes. Si cargando es cierto, error y exito deben ser falsos. Si exito es cierto, vacio habla de sus datos, no del acto de cargar. Las cinco banderas son, en realidad, una sola variable —el estado de la petición— disfrazada de cinco. Y el tipo booleano no sabe nada de esa correlación: te deja escribir cargando = true y error = true a la vez, un estado que tu cabeza sabe imposible pero que el compilador aplaude.
Y no es un caso rebuscado: es la evolución natural de casi todo estado de UI. Nadie diseña cinco booleanos correlacionados a propósito; se llega a ellos un useState cada vez, resolviendo el ticket de hoy sin releer los de ayer. Por eso la señal hay que aprenderla como un reflejo, para reconocerla en el segundo o tercer booleano y no en el quinto, cuando el render ya es un campo de minas de condiciones.
Diez piezas de estado genuinamente independientes —el nombre, el email, el tema claro/oscuro, el idioma— no piden una máquina: son un registro y viven felices en un store. La señal que pide más estructura es distinta: dos o más banderas que siempre se mueven juntas siguiendo reglas tácitas. Cuando te oigas decir “si esta es cierta, aquella tiene que ser falsa”, ya no tienes booleanos independientes: tienes una variable de estado partida en trozos que el tipo no reconoce.
Estados imposibles: el 2^n que nadie quiso
Cinco booleanos definen 2^5, es decir treinta y dos combinaciones. ¿Cuántas son legales? Cuatro o cinco: inactivo, cargando, error, exito con datos, exito vacio. Las otras veintisiete son estados imposibles, y su presencia se filtra al render como una maraña de condiciones defensivas:
// Vigilancia manual: cada if cubre una correlacion a mano, y siempre falta uno.
if (cargando && !error) return <Spinner />
if (error && !cargando) return <ErrorBox onReintentar={pedir} />
if (exito && datos.length === 0) return <Vacio />
if (exito) return <Lista datos={datos} />
return null // que estados caen aqui? nadie lo sabe con certeza
Cada uno de esos if no es lógica de negocio: es vigilancia manual, siempre incompleta, contra combinaciones que el tipo nunca debió permitir. El return null final es la confesión: cubre un conjunto de estados que nadie enumeró y que quizá incluya alguno legal olvidado. El bug no vive en ninguna línea concreta; vive en el hueco entre las que escribiste.
flowchart TD A[5 booleanos independientes] --> B[32 combinaciones representables] B --> C[solo 5 son legales] B --> D[27 estados imposibles] D --> E[cada uno es un bug latente] C --> F[una sola variable con 5 valores] style D fill:#f38ba8,color:#11111b style E fill:#f38ba8,color:#11111b style F fill:#a6e3a1,color:#11111b
Aquí conecta lo aprendido en el primer nivel: cada variable mutable que añades no suma un caso, duplica el universo de estados. La cura no es probar más combinaciones; es hacer irrepresentables las ilegales. Ese principio —convertir los estados imposibles en errores de compilación en vez de bugs de ejecución— es una de las ideas más rentables del diseño de tipos, y en TypeScript su herramienta es la unión discriminada: un tipo que enumera solo los estados legales y ata a cada uno exactamente los datos que le corresponden.
El coste de no hacerlo no es solo estético. Cada estado imposible representable es una entrada más en la tabla de verdad que tus tests tendrían que cubrir para estar seguros, y como nadie prueba las veintisiete combinaciones ilegales, quedan como territorio inexplorado donde anidan los bugs que “solo pasan a veces”. Reducir el tipo a sus cinco estados legales no solo limpia el render: encoge el espacio que hay que probar de treinta y dos a cinco.
El primer paso hacia esa cura ni siquiera necesita una máquina. Basta con reconocer que las cinco banderas son una sola variable y darle el tipo que le corresponde; las transiciones, si las hay, vienen después.
La escalera: del enum de status a la máquina
Entre el useState booleano y la máquina de XState hay peldaños intermedios, y saltarse el diagnóstico para ir directo al último es tan erróneo como quedarse en el primero. La escalera tiene cuatro peldaños.
1 · Booleano
Un estado con dos valores legales y sin correlaciones. useState(false). Correcto mientras no aparezca una segunda bandera que dependa de la primera.
2 · Enum de status
Varias banderas correlacionadas colapsan en un tipo unión. Los estados imposibles desaparecen del tipo. Aún sin reglas de transición explícitas.
3 · Unión discriminada
Cada estado lleva solo los datos que le corresponden: error tiene mensaje, exito tiene datos. El tipo impide leer datos que ese estado no posee.
4 · Máquina
Cuando además las transiciones tienen reglas —de error solo se reintenta, no se pasa a exito— y hay asincronía con cancelación, la máquina las vuelve explícitas.
El segundo y tercer peldaño resuelven la mitad del dolor y cuestan casi nada. Las cinco banderas se convierten en una unión discriminada:
type Estado =
| { status: 'inactivo' }
| { status: 'cargando' }
| { status: 'error'; mensaje: string }
| { status: 'exito'; datos: Dato[] } // vacio es datos.length === 0
const [estado, setEstado] = useState<Estado>({ status: 'inactivo' })
De golpe, cargando y error a la vez es un error de tipo, no un bug de runtime: los veintisiete estados imposibles dejaron de ser representables. Esto es, en esencia, una FSM sin transiciones explícitas —una unión discriminada es una máquina a la que solo le falta declarar las aristas—. No es casualidad que las librerías de datos de 2026 expongan justo esta forma: TanStack Query no te da cuatro booleanos, te da un campo status con 'pending' | 'error' | 'success', y useActionState de React 19 colapsa el estado de un envío en un solo valor. La industria ya subió al segundo peldaño por ti.
La pregunta para escalar del enum a la máquina no es “¿hay varios estados?” —eso lo resuelve el enum— sino “¿importan las transiciones entre ellos?”. Si desde cualquier estado puedes ir a cualquier otro sin reglas, el enum basta. Si existen aristas prohibidas —de exito no se vuelve a cargando sin pasar por un evento concreto, un error solo permite reintentar— entonces las transiciones son el problema, y ese es el trabajo de una máquina. Estados sin reglas: enum. Estados con reglas de paso: máquina.
Un beneficio colateral de colapsar las banderas en una unión discriminada es que el estado pasa a ser un único valor que se reemplaza entero en cada transición, en vez de cinco casillas que se mutan por separado. Eso encaja de forma natural con los reducers, con el structural sharing y con el viaje en el tiempo de las devtools: un estado, un valor, una historia. Cinco setState dispersos no dan esa propiedad; una sola variable de estado, sí.
El payoff: el render que deja de defenderse
Nombrar la variable no es cosmética: cambia la forma del código que la consume. El render defensivo de antes, lleno de conjunciones de booleanos, se convierte en un switch exhaustivo donde cada rama es un estado legal y solo uno.
// Con la union discriminada, el render es exhaustivo y sin vigilancia.
switch (estado.status) {
case 'inactivo': return <Boton onClick={pedir}>Cargar</Boton>
case 'cargando': return <Spinner />
case 'error': return <ErrorBox mensaje={estado.mensaje} onReintentar={pedir} />
case 'exito': return <Lista datos={estado.datos} />
}
Fíjate en estado.mensaje: el compilador solo te deja leerlo dentro de la rama error, porque es el único estado que lo posee; lo mismo con estado.datos en exito. Desaparecieron las conjunciones, desapareció el return null sospechoso, y desapareció la pregunta “¿qué casos caen aquí?”. Además, si un día añades el estado reintentando, el switch sin rama para él es un error de compilación con la comprobación de exhaustividad por never: el tipo te obliga a manejarlo en vez de dejar que se cuele por el hueco.
Ese es el sentido profundo de hacer irrepresentables los estados imposibles: no es que el compilador te regañe más, es que hay clases enteras de bug que dejan de poder escribirse. El render exhaustivo no está mejor vigilado que el defensivo; está liberado de vigilar, porque lo que vigilaba ya no existe.
Detente en el peldaño más bajo que elimine tus estados imposibles. Muchísimos problemas que se sienten “de máquina” se resuelven enteros en el segundo o tercer peldaño, sin transiciones explícitas, sin actores, sin dependencia. Subir al cuarto peldaño es correcto solo cuando las transiciones con reglas aparecen, y esa aparición es un hecho observable del dominio, no una preferencia estética. La escalera te protege de los dos errores simétricos: quedarte en booleanos cuando ya se correlacionaron, y saltar a XState cuando un enum bastaba.
Una última ventaja del enfoque incremental: la unión discriminada compone. El mismo valor estado entra sin fricción en un useReducer, en una action de Zustand o en el context de una máquina de XState el día que subas de peldaño. No es una decisión que haya que deshacer para avanzar; es el cimiento sobre el que se apoya el peldaño siguiente, y por eso invertir en nombrar bien la variable nunca se desperdicia.
Cuando ves cinco booleanos correlacionados en un componente, no estás viendo cinco decisiones de diseño: estás viendo una sola decisión que se tomó cinco veces sin darse cuenta. Cada bandera se añadió para resolver el caso de su día, con la vista puesta en un requisito y ciega a los otros cuatro, y la correlación entre ellas —la parte difícil, la que de verdad define el problema— nunca se escribió en ninguna parte: quedó implícita en la cabeza de quien las fue poniendo y se filtra al código solo como una maraña de condicionales defensivos. Por eso los cinco booleanos son una confesión: confiesan que existe una variable de estado que el programa nunca nombró. El acto de colapsarlos en una unión discriminada no es una optimización ni un truco de tipos; es dar nombre por fin a esa variable, sacarla de la penumbra y ponerla donde el compilador pueda razonar sobre ella. Y una vez nombrada, la pregunta siguiente —si además sus transiciones tienen reglas— casi se contesta sola, porque el dominio, que ya te había hablado a través de las correlaciones, te dirá también qué pasos entre estados permite y cuáles prohíbe. La máquina, cuando llega, no es un salto conceptual: es el último peldaño natural de una escalera que empezó el día que escribiste el segundo useState(false). El arte no es saber construir máquinas; es oír, en ese segundo booleano, la máquina que ya estaba naciendo, y decidir con la cabeza fría en qué peldaño el problema deja de crecer.
- Encuentra un componente con tres o más booleanos de estado. Enumera las
2^ncombinaciones y marca cuáles son legales; el resto son tus estados imposibles. - Colapsa esas banderas en una unión discriminada donde cada estado lleve solo sus datos. Comprueba que los estados imposibles ahora son errores de compilación.
- Reescribe el render como un
switchexhaustivo sobre elstatusy cuenta losifdefensivos que desaparecen. Cada uno era vigilancia de una correlación que ahora vive en el tipo. - Añade la comprobación de exhaustividad con
nevery verifica que agregar un estado nuevo rompe la compilación hasta que lo manejas. - Pregúntate si las transiciones tienen reglas: ¿hay pasos entre estados que el dominio prohíbe? Si no, quédate en el enum; si sí, esboza las aristas legales.
- Compara tu enum con el campo
statusde TanStack Query. Identifica qué te dio gratis la librería y qué tuviste que modelar tú por ser propio de tu dominio.