Crea una aplicación de cuestionarios con una máquina de estados
Crea una app de quiz con XState v5 en React. Modela cuatro pantallas con una máquina de estados, reemplaza flags booleanos y protege el resultado final.
Una máquina de estados reemplaza un montón de banderas booleanas por un único estado con nombre, de modo que un componente de React solo puede encontrarse en una de las pantallas que definiste deliberadamente.
Se te viene encima sin darte cuenta. Una pantalla empieza con un isLoading, gana un hasAnswered, después un isFinished, y en algún momento nadie sabe decir qué combinaciones son válidas. Este artículo modela una funcionalidad pequeña, un cuestionario, como una máquina de XState v5 con cuatro estados, y muestra qué se gana frente a la versión con banderas. Para una introducción más amplia a la biblioteca, el resumen de XState de OpenReplay es un buen punto de partida, aunque su código es anterior a la v5; aquí construimos un único archivo, de principio a fin. El código está dirigido a XState v5 y a la versión v6 de @xstate/react.
Puntos clave
- Tres banderas booleanas describen ocho combinaciones, pero un cuestionario solo tiene cuatro pantallas válidas, lo que deja cuatro estados que tu interfaz puede alcanzar y que tu diseño nunca contempló.
- En XState, los estados describen lo que el usuario puede hacer en este momento, mientras que el contexto contiene los datos sobre los que operan esos estados: “answered” es un estado, pero la lista de preguntas, el índice actual y la puntuación pertenecen al contexto.
- En XState v5, un guard es una función que recibe
{ context, event }y responde true o false, y una respuesta false significa que la transición en la que se encuentra se omite. - Añadir un paso de revisión a un componente basado en banderas implica una cuarta bandera y dieciséis combinaciones; añadirlo a una máquina implica un estado nuevo y sus transiciones.
El cuestionario y sus cuatro estados
El cuestionario tiene exactamente cuatro pantallas, y cada una corresponde a un estado con nombre. idle: la pantalla de inicio, nada cargado, un botón. question: hay una pregunta en pantalla y las opciones son clicables. answered: el usuario ha elegido una opción, la retroalimentación es visible y aparece un botón Next. results: la puntuación final, con la posibilidad de reiniciar. Cada render que el componente llegue a producir pertenece a una de estas cuatro.
¿Por qué se desmoronan las banderas booleanas?
Tres banderas booleanas, isLoading, hasAnswered e isFinished, describen ocho combinaciones posibles, pero el cuestionario solo tiene cuatro pantallas válidas. Eso deja cuatro estados que tu interfaz puede alcanzar físicamente y que tu diseño nunca contempló.
| isLoading | hasAnswered | isFinished | Pantalla |
|---|---|---|---|
| false | false | false | question |
| true | false | false | cargando la siguiente pregunta |
| false | true | false | answered |
| false | false | true | results |
| true | true | false | ninguna (respondida mientras carga) |
| true | false | true | ninguna |
| false | true | true | ninguna |
| true | true | true | ninguna |
La primera fila ilegal no es hipotética. Así es como ocurre:
function goToNext() {
setIsLoading(true);
fetchQuestion(index + 1).then((q) => {
setQuestion(q);
setHasAnswered(false); // reset arrives only when the fetch resolves
setIsLoading(false);
});
}
El usuario responde, hace clic en Next y, hasta que se resuelve el fetch, el componente mantiene isLoading: true y hasAnswered: true de forma simultánea: un spinner junto a la respuesta resaltada de la pregunta anterior. Nada relaciona ambas banderas, así que nada lo impide. En las repeticiones de sesión de flujos de cuestionarios y asistentes, este tipo de error tiene una firma reconocible: una pantalla que no debería existir, como una respuesta resaltada sin ninguna pregunta renderizada, que es el aspecto que tiene una combinación de banderas no enumerada desde el lado del usuario.
Modelar el cuestionario como una máquina de estados en React
La máquina nombra los cuatro estados, los eventos que permiten pasar de uno a otro, y nada más, de modo que cada transición es explícita y todo lo que no está listado resulta imposible. createMachine recibe la definición completa como un único objeto:
import { createMachine, assign } from 'xstate';
const questions = [
{ text: '2 + 2?', options: ['3', '4'], answer: 1 },
{ text: 'Capital of France?', options: ['Paris', 'Lyon'], answer: 0 },
{ text: 'Largest planet?', options: ['Earth', 'Jupiter'], answer: 1 },
];
export const quizMachine = createMachine({
id: 'quiz',
initial: 'idle',
context: { questions, currentIndex: 0, score: 0 },
states: {
idle: {
on: { START: { target: 'question' } },
},
question: {
on: {
ANSWER: {
target: 'answered',
actions: assign({
score: ({ context, event }) =>
event.optionIndex === context.questions[context.currentIndex].answer
? context.score + 1
: context.score,
}),
},
},
},
answered: {
on: {
NEXT: {
target: 'question',
actions: assign({
currentIndex: ({ context }) => context.currentIndex + 1,
}),
},
},
},
results: {
on: {
RESTART: {
target: 'idle',
actions: assign({ currentIndex: 0, score: 0 }),
},
},
},
},
});
Un evento ANSWER en results no hace nada, por construcción y no por comprobaciones defensivas con if. Queda un hueco: todavía nada llega a results. La sección sobre guards lo cierra.
Contexto frente a estado: los datos no son un estado
Los estados describen lo que el usuario puede hacer en este momento; el contexto contiene los datos sobre los que operan esos estados. Para este cuestionario, “answered” es un estado porque cambia qué controles funcionan, mientras que la lista de preguntas, el índice actual y la puntuación son contexto porque cambian lo que se muestra dentro de una pantalla, no cuál es la pantalla. El criterio es trasladable: si un valor cambia lo que el usuario puede hacer, modélalo como un estado; si cambia lo que ve dentro de la misma pantalla, ponlo en el contexto y actualízalo con assign, cuyo callback recibe { context, event } como se muestra arriba. Codificar las preguntas de forma fija mantiene el foco de este artículo; para cargarlas por red, invoca un actor fromPromise en un estado de carga y usa assign a partir de event.output.
Conectar la máquina a un componente de React
El hook useMachine de @xstate/react devuelve tres cosas en un array: el snapshot actual, una función send y una referencia al actor en ejecución. El componente de abajo toma los dos primeros, renderiza a partir de snapshot.value y comunica las acciones del usuario como eventos en lugar de establecer banderas.
import { useMachine } from '@xstate/react';
import { quizMachine } from './quizMachine';
export default function Quiz() {
const [snapshot, send] = useMachine(quizMachine);
const { questions, currentIndex, score } = snapshot.context;
const question = questions[currentIndex];
if (snapshot.value === 'idle')
return <button onClick={() => send({ type: 'START' })}>Start quiz</button>;
if (snapshot.value === 'results')
return (
<div>
<p>Score: {score} / {questions.length}</p>
<button onClick={() => send({ type: 'RESTART' })}>Play again</button>
</div>
);
return (
<div>
<p>{question.text}</p>
{question.options.map((option, i) => (
<button
key={option}
disabled={snapshot.value === 'answered'}
onClick={() => send({ type: 'ANSWER', optionIndex: i })}
>
{option}
</button>
))}
{snapshot.value === 'answered' && (
<button onClick={() => send({ type: 'NEXT' })}>Next</button>
)}
</div>
);
}
No hay contabilidad de banderas en los manejadores. El componente enuncia hechos (“el usuario respondió”) y la máquina decide qué significan.
¿Cómo proteges la condición de finalización?
Un guard es una comprobación pequeña y sin efectos secundarios. XState le entrega el contexto de la máquina y el evento que acaba de llegar, y devuelve true o false. Una respuesta false significa que la transición en la que se encuentra se omite. Para dirigir la última respuesta hacia results, sustituye la transición NEXT por un array de transiciones con guard. XState recorre ese array de arriba abajo, usa la primera entrada cuyo guard devuelva true y llega a la entrada sin guard del final solo cuando ninguna lo hace:
answered: {
on: {
NEXT: [
{
guard: ({ context }) =>
context.currentIndex >= context.questions.length - 1,
target: 'results',
},
{
target: 'question',
actions: assign({
currentIndex: ({ context }) => context.currentIndex + 1,
}),
},
],
},
},
La condición de finalización vive ahora en un solo lugar, en vez de volver a deducirse en cada manejador que toca el índice.
¿Qué te aporta la máquina?
Las filas ilegales de la tabla anterior son ahora inalcanzables, no simplemente improbables: ninguna secuencia de eventos deja a la máquina en “respondida mientras carga” porque ninguna transición lleva allí. La segunda ventaja es el cambio. Añadir un paso de revisión a la versión con booleanos implica una cuarta bandera y dieciséis combinaciones que razonar; añadirlo a la máquina implica un nuevo estado review, una transición hacia él desde results y otra de vuelta. El componente gana una rama if, y todos los estados existentes siguen comportándose exactamente igual que antes.
Dónde aplicar este patrón a continuación
El patrón escala hacia abajo más de lo que sugieren la mayoría de los tutoriales: cualquier pantalla en la que te sorprendas escribiendo if (isX && !isY) es candidata a tener cuatro o cinco estados con nombre. Toma el archivo de la máquina de este artículo, cambia los estados y eventos por los de tu propio dominio y haz que el tipo de error de esa tabla de combinaciones pase a ser algo que tu componente no puede ni expresar.
Preguntas frecuentes
¿Cuál es la diferencia entre una máquina de XState y useReducer?
Ambos centralizan las transiciones en una única función, pero un reducer acepta cualquier acción en cualquier estado, por lo que las combinaciones ilegales siguen siendo expresables. Una máquina de estados solo responde a los eventos listados bajo su estado actual: un evento ANSWER recibido en el estado results se ignora por construcción. Un reducer además deja el conjunto de estados posibles implícito en sus datos, mientras que una máquina nombra cada estado de forma explícita.
¿XState v5 requiere TypeScript?
No. XState v5 funciona en JavaScript puro, y todos los fragmentos de este artículo funcionan sin tipos. Si usas TypeScript, la versión mínima admitida es la 5.0. Con TypeScript, la API setup() permite declarar tipos para el contexto y los eventos, de modo que las transiciones y las llamadas a assign se comprueban en tiempo de compilación.
¿El código de tutoriales de XState v4 funciona en la v5?
No, varias APIs centrales cambiaron de nombre en la migración de la v4 a la v5. Machine() pasó a ser createMachine(), interpret() pasó a ser createActor(), la propiedad cond de las transiciones pasó a ser guard y los servicios invocados pasaron a ser actores. Las firmas de los callbacks también cambiaron: las actions y los guards reciben ahora un único objeto que contiene context y event en lugar de argumentos separados. Si un fragmento usa cond o interpret, es código de la v4 y no funcionará en la v5.
¿Pueden dos componentes de React compartir el estado de la misma máquina de XState?
No llamando a useMachine dos veces: cada llamada crea un actor independiente con su propio snapshot, así que dos componentes que usen useMachine(quizMachine) mantienen estados separados y no sincronizados. Para compartir una única máquina en ejecución, crea el actor una sola vez y distribúyelo, ya sea con createActorContext del paquete xstate react o pasando hacia abajo como prop el actorRef que devuelve useMachine y leyéndolo con useSelector.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k