
Si usas useState para una interfaz compleja, probablemente lo estás haciendo mal. Hay una solución de hace 50 años que estás ignorando.
Este es un patrón conocido:
function UserProfile() {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const handleSave = async () => {
setIsSaving(true);
setIsError(false);
try {
await saveUser(user);
setIsEditing(false);
} catch (e) {
setIsError(true);
setErrorMessage(e.message);
} finally {
setIsSaving(false);
}
};
// What combinations are valid?
// isLoading=true, isError=true?
// isEditing=true, isSaving=true?
// Who knows!
}Esto crea 2^n estados posibles (32 combinaciones para 5 booleanos). La mayoría son inválidas o absurdas.
¿Y si definieras los estados válidos de forma explícita?
{
"states": [
{ "name": "idle", "isInitial": true },
{ "name": "loading" },
{ "name": "editing" },
{ "name": "saving" },
{ "name": "error" }
],
"events": ["FETCH", "EDIT", "SAVE", "SUCCESS", "ERROR", "CANCEL"],
"transitions": [
{ "from": "idle", "to": "loading", "event": "FETCH" },
{ "from": "loading", "to": "idle", "event": "SUCCESS" },
{ "from": "loading", "to": "error", "event": "ERROR" },
{ "from": "idle", "to": "editing", "event": "EDIT" },
{ "from": "editing", "to": "saving", "event": "SAVE" },
{ "from": "saving", "to": "idle", "event": "SUCCESS" },
{ "from": "saving", "to": "error", "event": "ERROR" },
{ "from": "editing", "to": "idle", "event": "CANCEL" },
{ "from": "error", "to": "idle", "event": "CANCEL" }
]
}Ahora hay exactamente 5 estados y 9 transiciones válidas. Ninguna combinación imposible.
isLoading=true
/ \
isError=true? isEditing=true?
/ \
? ?Cualquier combinación es posible. Los errores nacen de estados inválidos que no consideraste.
┌─────────┐
┌─────────►│ idle │◄────────┐
│ └────┬────┘ │
│ │ │
ERROR│ FETCH│ SUCCESS
│ ▼ │
┌────┴───┐ ┌─────────┐ │
│ error │ │ loading │ │
└───┬────┘ └────┬────┘ │
▲ │ │
│ SUCCESS │
│ │ │
│ ▼ │
│ ┌─────────┐ │
└───────────┤ editing ├────────┘
└────┬────┘
│ SAVE
▼
┌─────────┐
┌─────────│ saving │─────────┐
│ └─────────┘ │
ERROR│ │SUCCESS
│ │
└──────────────────────────────┘Cada camino es explícito. Las transiciones inválidas no existen.
function ContactForm() {
const [formData, setFormData] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const submit = async () => {
setIsSubmitting(true);
setIsError(false);
setIsSuccess(false);
try {
await api.submit(formData);
setIsSuccess(true);
} catch (e) {
setIsError(true);
setErrorMessage(e.message);
} finally {
setIsSubmitting(false);
}
};
// Bug: What if isSuccess and isError are both true?
// Bug: Can I submit again while isSubmitting?
// Bug: What clears isSuccess?
}Define estados y transiciones explícitos:
{
"states": [
{ "name": "editing", "isInitial": true },
{ "name": "validating" },
{ "name": "submitting" },
{ "name": "success", "isTerminal": true },
{ "name": "error" }
],
"events": ["SUBMIT", "VALIDATED", "SUCCESS", "FAILURE", "RETRY", "EDIT"],
"transitions": [
{ "from": "editing", "to": "validating", "event": "SUBMIT" },
{
"from": "validating",
"to": "submitting",
"event": "VALIDATED",
"guard": ["=", "@validation.valid", true]
},
{
"from": "validating",
"to": "editing",
"event": "VALIDATED",
"guard": ["=", "@validation.valid", false]
},
{ "from": "submitting", "to": "success", "event": "SUCCESS" },
{ "from": "submitting", "to": "error", "event": "FAILURE" },
{ "from": "error", "to": "editing", "event": "RETRY" }
]
}Ventajas:
Si usas XState, esto se traduce directamente. Si prefieres algo más ligero, incluso una unión discriminada (discriminated union) simple de TypeScript te lleva la mayor parte del camino:
type FormState =
| { status: 'editing' }
| { status: 'validating' }
| { status: 'submitting' }
| { status: 'success' }
| { status: 'error'; message: string };Realidad: las banderas booleanas parecen más simples hasta que tienes 5 o más. Entonces la matriz de interacciones se vuelve incomprensible.
Realidad: los desarrolladores de juegos usan máquinas de estados finitos porque funcionan. La interfaz es igual que un juego: las acciones del usuario provocan cambios de estado.
Realidad: cambiar una máquina de estados significa añadir un estado o una transición. Cambiar banderas booleanas significa buscar entre cadenas de useEffect.
| Escenario | Banderas booleanas | Máquina de estados |
|---|---|---|
| 2-3 estados simples | ✅ Aceptable | ✅ Mejor |
| Operaciones asíncronas | ❌ Llenas de errores | ✅ Claro |
| Flujos de varios pasos | ❌ Desordenado | ✅ Perfecto |
| Modos de interfaz complejos | ❌ Imposible | ✅ Ideal |
Los semáforos son la máquina de estados canónica:
Red → Green → Yellow → RedImagina que los semáforos usaran banderas booleanas:
const [isRed, setIsRed] = useState(true);
const [isGreen, setIsGreen] = useState(false);
const [isYellow, setIsYellow] = useState(false);
// Bug: All could be true!
// Bug: All could be false!
// Bug: Green could turn directly to Red!Los ingenieros de tráfico usan máquinas de estados porque hay vidas que dependen de estados predecibles.
La cordura de tus usuarios también depende de ello.
Toma este componente cargado de booleanos:
function Checkout() {
const [isCartOpen, setIsCartOpen] = useState(false);
const [isCheckingOut, setIsCheckingOut] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [hasError, setHasError] = useState(false);
// ... nightmare of useEffect
}Conviértelo a estados explícitos:
states: browsing → cartOpen → checkoutForm → processing → complete
↘ error → checkoutForm (retry)La versión con máquina de estados tiene 6 estados explícitos en lugar de 32 combinaciones booleanas posibles.
Las máquinas de estados finitos no son ejercicios académicos: son herramientas prácticas para gestionar la complejidad.
Bibliotecas como XState lo hacen fácil en cualquier proyecto de React/Vue/Svelte.