Construa um App de Quiz Com uma Máquina de Estados
Crie um app de quiz com XState v5 no React. Modele quatro telas como state machine, troque boolean flags e proteja o estado final.
Uma máquina de estados substitui uma pilha de flags booleanas por um único estado nomeado, de modo que um componente React só possa estar em uma das telas que você deliberadamente definiu.
Isso acontece aos poucos. Uma tela começa com um isLoading, ganha um hasAnswered, depois um isFinished, e em algum momento ninguém mais sabe dizer quais combinações são válidas. Este artigo modela uma pequena funcionalidade, um quiz, como uma máquina XState v5 com quatro estados, e mostra o que se ganha em relação à versão com flags. Para uma introdução mais ampla à biblioteca, o panorama sobre XState do OpenReplay é um bom ponto de partida, embora seu código seja anterior à v5; aqui construímos um único arquivo, de ponta a ponta. O código tem como alvo o XState v5 e a versão v6 do @xstate/react.
Principais Conclusões
- Três flags booleanas descrevem oito combinações, mas um quiz tem apenas quatro telas válidas, deixando quatro estados que sua UI pode alcançar e que seu design nunca previu.
- No XState, os estados descrevem o que o usuário pode fazer neste momento, enquanto o context guarda os dados sobre os quais esses estados operam: “answered” é um estado, mas a lista de perguntas, o índice atual e a pontuação pertencem ao context.
- No XState v5, um guard é uma função que recebe
{ context, event }e responde verdadeiro ou falso, e uma resposta falsa significa que a transição em que ele está é ignorada. - Adicionar uma etapa de revisão a um componente baseado em flags significa uma quarta flag e dezesseis combinações; adicioná-la a uma máquina significa um novo estado e suas transições.
O Quiz e Seus Quatro Estados
O quiz tem exatamente quatro telas, e cada uma corresponde a um estado nomeado. idle: a tela inicial, nada carregado, um botão. question: uma pergunta está na tela e as opções são clicáveis. answered: o usuário escolheu uma opção, o feedback está visível e um botão Next aparece. results: a pontuação final, com a oferta de reiniciar. Toda renderização que o componente produzirá pertence a uma dessas quatro.
Por Que as Flags Booleanas Desmoronam?
Três flags booleanas, isLoading, hasAnswered e isFinished, descrevem oito combinações possíveis, mas o quiz tem apenas quatro telas válidas. Isso deixa quatro estados que sua UI pode fisicamente alcançar e que seu design nunca previu.
| isLoading | hasAnswered | isFinished | Tela |
|---|---|---|---|
| false | false | false | question |
| true | false | false | carregando próxima pergunta |
| false | true | false | answered |
| false | false | true | results |
| true | true | false | nenhuma (respondida durante o carregamento) |
| true | false | true | nenhuma |
| false | true | true | nenhuma |
| true | true | true | nenhuma |
A primeira linha inválida não é hipotética. Veja como ela acontece:
function goToNext() {
setIsLoading(true);
fetchQuestion(index + 1).then((q) => {
setQuestion(q);
setHasAnswered(false); // reset arrives only when the fetch resolves
setIsLoading(false);
});
}
O usuário responde, clica em Next, e até que o fetch seja resolvido o componente mantém isLoading: true e hasAnswered: true simultaneamente: um spinner ao lado da resposta destacada da última pergunta. Nada relaciona as duas flags, então nada impede isso. Em session replays de fluxos de quiz e wizard, essa classe de bug tem uma assinatura reconhecível: uma tela que não deveria existir, como uma resposta destacada sem nenhuma pergunta renderizada, que é como uma combinação de flags não enumerada aparece do lado do usuário.
Modelando o Quiz como uma Máquina de Estados em React
A máquina nomeia os quatro estados, os eventos que transitam entre eles, e nada mais, de modo que toda transição é explícita e tudo o que não está listado é impossível. createMachine recebe toda a definição como um ú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 }),
},
},
},
},
});
Um evento ANSWER em results não faz nada, por construção e não por checagens defensivas com if. Resta uma lacuna: nada chega a results ainda. A seção sobre guards resolve isso.
Context Versus Estado: Dados Não São um Estado
Os estados descrevem o que o usuário pode fazer neste momento; o context guarda os dados sobre os quais esses estados operam. Para este quiz, “answered” é um estado porque muda quais controles funcionam, enquanto a lista de perguntas, o índice atual e a pontuação são context porque mudam o que é exibido dentro de uma tela, e não qual é a tela. O critério é portável: se um valor muda o que o usuário pode fazer, modele-o como um estado; se muda o que ele vê dentro da mesma tela, coloque-o no context e atualize-o com assign, cujo callback recebe { context, event } conforme mostrado acima. Deixar as perguntas fixas no código mantém este artigo focado; para carregá-las pela rede, invoque um ator fromPromise em um estado de loading e faça assign a partir de event.output.
Conectando a Máquina a um Componente React
O hook useMachine de @xstate/react devolve três coisas em um array: o snapshot atual, uma função send e uma referência ao ator em execução. O componente abaixo usa os dois primeiros, renderiza a partir de snapshot.value e reporta ações do usuário como eventos em vez de definir flags.
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>
);
}
Não há controle de flags nos handlers. O componente afirma fatos (“o usuário respondeu”) e a máquina decide o que eles significam.
Como Proteger a Condição de Término?
Um guard é uma pequena verificação sem efeitos colaterais. O XState lhe entrega o context da máquina e o evento que acabou de chegar, e ele responde verdadeiro ou falso. Uma resposta falsa significa que a transição em que ele está é ignorada. Para direcionar a última resposta para results, substitua a transição NEXT por um array de transições com guard. O XState percorre esse array de cima para baixo, usa a primeira entrada cujo guard responde verdadeiro e só chega à entrada sem guard no final quando nenhuma delas responde:
answered: {
on: {
NEXT: [
{
guard: ({ context }) =>
context.currentIndex >= context.questions.length - 1,
target: 'results',
},
{
target: 'question',
actions: assign({
currentIndex: ({ context }) => context.currentIndex + 1,
}),
},
],
},
},
A condição de término agora vive em um único lugar, em vez de ser rederivada em cada handler que toca no índice.
O Que a Máquina Traz de Ganho?
As linhas inválidas da tabela acima agora são inalcançáveis, não meramente improváveis: nenhuma sequência de eventos coloca a máquina em “respondida durante o carregamento” porque nenhuma transição leva até lá. O segundo ganho é a mudança. Adicionar uma etapa de revisão à versão booleana significa uma quarta flag e dezesseis combinações a considerar; adicioná-la à máquina significa um novo estado review, uma transição para ele a partir de results e uma de volta. O componente ganha um ramo if, e todos os estados existentes continuam se comportando exatamente como antes.
Onde Usar Esse Padrão a Seguir
O padrão escala para baixo mais do que a maioria dos tutoriais sugere: qualquer tela em que você se pegue escrevendo if (isX && !isY) é candidata a quatro ou cinco estados nomeados. Pegue o arquivo da máquina deste artigo, troque pelos estados e eventos do seu próprio domínio, e faça com que o tipo de bug daquela tabela de combinações se torne algo que seu componente não consegue sequer expressar.
Perguntas Frequentes
Qual é a diferença entre uma máquina do XState e useReducer?
Ambos centralizam as transições em uma única função, mas um reducer aceita qualquer action em qualquer estado, de modo que combinações inválidas continuam expressáveis. Uma máquina de estados só responde a eventos listados sob seu estado atual: um evento ANSWER recebido no estado results é ignorado por construção. Um reducer também deixa o conjunto de estados possíveis implícito em seus dados, enquanto uma máquina nomeia cada estado explicitamente.
O XState v5 exige TypeScript?
Não. O XState v5 funciona em JavaScript puro, e todos os trechos deste artigo funcionam sem tipos. Se você usar TypeScript, a versão mínima suportada é a 5.0. Com TypeScript, a API setup() permite declarar tipos para context e eventos, de modo que transições e chamadas de assign sejam verificadas em tempo de compilação.
Código de tutoriais do XState v4 roda na v5?
Não, várias APIs centrais foram renomeadas na migração da v4 para a v5. Machine() virou createMachine(), interpret() virou createActor(), a propriedade cond nas transições virou guard, e os services invocados viraram actors. As assinaturas de callback também mudaram: actions e guards agora recebem um único objeto contendo context e event em vez de argumentos separados. Se um trecho usa cond ou interpret, é código v4 e não vai rodar na v5.
Dois componentes React podem compartilhar o mesmo estado de uma máquina XState?
Não chamando useMachine duas vezes: cada chamada cria um ator independente com seu próprio snapshot, então dois componentes usando useMachine(quizMachine) mantêm estados separados e não sincronizados. Para compartilhar uma única máquina em execução, crie o ator uma vez e distribua-o, seja com createActorContext do pacote xstate react ou passando o actorRef retornado por useMachine adiante como prop e lendo-o com 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