O Que São State Machines e Por Que Devemos Usar?
Uma máquina de estados é um modelo matemático que descreve o comportamento de um sistema através de estados bem definidos e transições entre eles. Diferentemente de código imperativo tradicional, onde você gerencia o estado através de várias variáveis booleanas e condicionais espalhadas pelo aplicativo, uma máquina de estados centraliza essa lógica em um diagrama claro e testável.
O grande problema ao construir aplicações complexas é o "gerenciamento caótico de estado". Imagine um formulário de login: ele pode estar em repouso, carregando, sucesso ou erro. Com useState tradicionais, você termina com múltiplas flags booleanas que podem entrar em combinações inválidas. Uma máquina de estados garante que você nunca entre em um estado impossível. XState é uma biblioteca que implementa essa filosofia de forma elegante em React, permitindo que você defina máquinas de estado complexas de forma declarativa.
A abordagem baseada em máquinas de estados também melhora significativamente a manutenibilidade. Quando o comportamento da aplicação está explícito no diagrama de estados, é mais fácil adicionar features, debugar bugs e comunicar a lógica com designers e product managers. Você documenta o comportamento da aplicação simultaneamente ao implementá-la.
Conceitos Fundamentais do XState
Estados e Transições
Em XState, um estado é uma situação bem definida em que seu componente ou aplicação pode estar. Uma transição é o movimento de um estado para outro, geralmente acionado por um evento. Vejamos um exemplo concreto com uma máquina de semáforo:
import { createMachine } from 'xstate';
const semáforoMachine = createMachine({
id: 'semáforo',
initial: 'vermelho',
states: {
vermelho: {
on: {
NEXT: 'amarelo'
}
},
amarelo: {
on: {
NEXT: 'verde'
}
},
verde: {
on: {
NEXT: 'vermelho'
}
}
}
});
Aqui temos três estados (vermelho, amarelo, verde) e um evento (NEXT) que permite transições. Cada estado define para quais estados pode transicionar quando um evento específico ocorre. Isso é fundamental: você não pode ir de vermelho para verde diretamente porque essa transição não está definida.
Contexto: Armazenando Dados Associados
Estados descrevem onde você está, mas frequentemente você precisa armazenar dados associados àquele estado. XState chama isso de contexto. Se um estado representa "carregando dados do servidor", o contexto pode armazenar os dados já obtidos, o erro que ocorreu, ou um ID do recurso sendo carregado.
import { createMachine } from 'xstate';
const fetchMachine = createMachine({
id: 'fetch',
initial: 'idle',
context: {
data: null,
error: null
},
states: {
idle: {
on: {
FETCH: 'loading'
}
},
loading: {
on: {
SUCCESS: {
target: 'success',
actions: 'setData'
},
ERROR: {
target: 'error',
actions: 'setError'
}
}
},
success: {
on: {
RESET: {
target: 'idle',
actions: 'resetContext'
}
}
},
error: {
on: {
RETRY: 'loading',
RESET: 'idle'
}
}
}
}, {
actions: {
setData: (context, event) => {
context.data = event.data;
},
setError: (context, event) => {
context.error = event.error;
},
resetContext: (context) => {
context.data = null;
context.error = null;
}
}
});
O contexto é o segundo parâmetro onde você define actions. Estas são funções que executam quando transições ocorrem, permitindo que você modifique o contexto conforme necessário. Isso mantém a lógica de transformação de dados próxima ao fluxo de estado.
Guards: Transições Condicionais
Nem toda transição deve sempre ser possível. Guards (guardas) são condições que devem ser verdadeiras para que uma transição ocorra. Imagine um checkout de e-commerce: você só pode ir para "pagamento" se há itens no carrinho.
const checkoutMachine = createMachine({
id: 'checkout',
initial: 'cart',
context: {
items: []
},
states: {
cart: {
on: {
PROCEED: [
{
target: 'shipping',
cond: (context) => context.items.length > 0
},
{
target: 'cart'
}
]
}
},
shipping: {
on: {
BACK: 'cart',
CONFIRM: 'payment'
}
},
payment: {
on: {
BACK: 'shipping'
}
}
}
});
Quando há múltiplas transições para o mesmo evento, XState avalia os cond (conditions) em ordem. A primeira que passar é executada. Se nenhuma passar, nenhuma transição ocorre. Isso é muito mais legível do que aninhamento de if-else espalhado pelo código.
Integrando XState com React: useMachine e Interpretação
O Hook useMachine
XState fornece um hook chamado useMachine que conecta uma máquina de estado ao ciclo de vida do React. Este hook retorna o estado atual e uma função send para disparar eventos.
import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';
const toggleMachine = createMachine({
id: 'toggle',
initial: 'off',
states: {
off: {
on: { TOGGLE: 'on' }
},
on: {
on: { TOGGLE: 'off' }
}
}
});
function ToggleButton() {
const [state, send] = useMachine(toggleMachine);
return (
<div>
<p>Estado: {state.value}</p>
<button onClick={() => send('TOGGLE')}>
Alternar
</button>
</div>
);
}
state.value contém o estado atual como string. Quando você chama send('TOGGLE'), XState processa o evento, executa qualquer action definida, e atualiza o estado React. O componente re-renderiza automaticamente.
Um Exemplo Completo: Formulário com Validação
Vamos construir um formulário de cadastro que demonstra como tudo funciona junto:
import { createMachine } from 'xstate';
import { useMachine } from '@xstate/react';
import { useState } from 'react';
const formMachine = createMachine({
id: 'form',
initial: 'editing',
context: {
name: '',
email: '',
errors: {}
},
states: {
editing: {
on: {
CHANGE: {
actions: 'updateField'
},
SUBMIT: [
{
target: 'validating',
cond: (context) => context.name.length > 0
},
{
target: 'editing',
actions: 'setNameError'
}
]
}
},
validating: {
invoke: {
src: (context) => validateEmail(context.email),
onDone: {
target: 'submitting',
actions: 'assignData'
},
onError: {
target: 'editing',
actions: 'setEmailError'
}
}
},
submitting: {
invoke: {
src: (context) => submitForm(context),
onDone: 'success',
onError: {
target: 'editing',
actions: 'setSubmitError'
}
}
},
success: {
on: {
RESET: 'editing'
}
}
}
}, {
actions: {
updateField: (context, event) => {
context[event.field] = event.value;
context.errors = {};
},
setNameError: (context) => {
context.errors.name = 'Nome é obrigatório';
},
setEmailError: (context) => {
context.errors.email = 'Email inválido';
},
setSubmitError: (context, event) => {
context.errors.submit = event.data.message;
},
assignData: (context, event) => {
context.submitData = event.data;
}
}
});
async function validateEmail(email) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (email.includes('@')) {
resolve();
} else {
reject(new Error('Email inválido'));
}
}, 500);
});
}
async function submitForm(context) {
const response = await fetch('/api/form', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: context.name,
email: context.email
})
});
if (!response.ok) {
throw new Error('Falha ao enviar');
}
return response.json();
}
function CadastroForm() {
const [state, send] = useMachine(formMachine);
return (
<div>
{state.matches('success') ? (
<div>
<p>Cadastro realizado com sucesso!</p>
<button onClick={() => send('RESET')}>Novo Cadastro</button>
</div>
) : (
<form onSubmit={(e) => {
e.preventDefault();
send('SUBMIT');
}}>
<input
type="text"
placeholder="Nome"
value={state.context.name}
onChange={(e) => send({
type: 'CHANGE',
field: 'name',
value: e.target.value
})}
/>
{state.context.errors.name && (
<p style={{ color: 'red' }}>{state.context.errors.name}</p>
)}
<input
type="email"
placeholder="Email"
value={state.context.email}
onChange={(e) => send({
type: 'CHANGE',
field: 'email',
value: e.target.value
})}
/>
{state.context.errors.email && (
<p style={{ color: 'red' }}>{state.context.errors.email}</p>
)}
<button
type="submit"
disabled={state.matches('validating') || state.matches('submitting')}
>
{state.matches('validating') ? 'Validando...' :
state.matches('submitting') ? 'Enviando...' :
'Cadastrar'}
</button>
{state.context.errors.submit && (
<p style={{ color: 'red' }}>{state.context.errors.submit}</p>
)}
</form>
)}
</div>
);
}
export default CadastroForm;
Este exemplo mostra como invoke funciona: você pode entrar em um estado que executa código assíncrono (como validação ou chamadas à API), e transicionar para outro estado baseado no sucesso ou falha. O fluxo fica explícito e legível: edição → validação → envio → sucesso ou volta para edição com erro.
Padrões Avançados e Boas Práticas
Máquinas Hierárquicas e Estados Compostos
Conforme suas máquinas crescem, você pode organizá-las hierarquicamente. Estados podem conter sub-estados, e você pode transicionar entre sub-estados sem deixar o estado pai.
const checkoutMachine = createMachine({
id: 'checkout',
initial: 'cart',
states: {
cart: {
on: { PROCEED: 'payment' }
},
payment: {
initial: 'method',
states: {
method: {
on: { SELECT: 'review' }
},
review: {
on: { CONFIRM: '#checkout.confirmation' }
}
},
on: { CANCEL: 'cart' }
},
confirmation: {
type: 'final'
}
}
});
O #checkout.confirmation é um reference externo para o estado final. Estados compostos ajudam a manter sub-fluxos isolados enquanto mantêm a hierarquia clara.
Reutilização de Máquinas com Composição
Em aplicações reais, você quer reutilizar máquinas. Você pode exportar uma máquina de um arquivo e importá-la em vários componentes, ou até mesmo compor máquinas menores em máquinas maiores.
// loginMachine.js
export const loginMachine = createMachine({
id: 'login',
initial: 'idle',
states: {
idle: {
on: { SUBMIT: 'authenticating' }
},
authenticating: {
invoke: {
src: (context) => authenticateUser(context.credentials),
onDone: 'success',
onError: 'error'
}
},
success: { type: 'final' },
error: {
on: { RETRY: 'idle' }
}
}
});
// appMachine.js
import { loginMachine } from './loginMachine';
export const appMachine = createMachine({
id: 'app',
initial: 'login',
states: {
login: {
invoke: {
src: loginMachine,
onDone: 'dashboard'
}
},
dashboard: {
on: { LOGOUT: 'login' }
}
}
});
Assim, você pode testar loginMachine isoladamente, reutilizá-la em múltiplos contextos, e manter a complexidade gerenciável.
Debugging e Visualização
XState fornece ferramentas excelentes para debugging. Use o XState Visualizer para visualizar suas máquinas e testar transições interativamente. Para debugging em tempo de execução, você pode usar:
import { useMachine } from '@xstate/react';
import { inspect } from 'xstate';
// No seu arquivo principal
inspect({
url: 'ws://localhost:8888'
});
function MyComponent() {
const [state, send] = useMachine(myMachine);
// O estado será enviado para o inspector
console.log('Estado atual:', state.value);
console.log('Contexto:', state.context);
return /* seu JSX */;
}
Conclusão
Aprendemos que máquinas de estados eliminam o caos do gerenciamento de estado imperativo ao forçar você a pensar no comportamento da aplicação como um diagrama finito e explícito. XState implementa essa abstração de forma elegante em React, permitindo que você defina complexidade com clareza.
O segundo ponto crucial é que integração com React através do useMachine é simples e poderosa: você dispara eventos com send(), reage a mudanças de estado com state.value, e acessa dados com state.context. A biblioteca cuida da sincronização com o ciclo de vida do React automaticamente.
Por fim, as boas práticas de hierarquia, composição e reutilização de máquinas transformam XState de uma ferramenta bacana em uma abordagem arquitetural séria para aplicações React complexas. Máquinas bem estruturadas servem como documentação viva do comportamento esperado, facilitando manutenção e evoluções futuras.