State machines (a.k.a Finite State Machines) are a mathematical model of computation. At their core they are very simple: They are just a set of states, and transitions between those states.
As a computational model with defined states and transitions, state machines are effective in managing complex processes, particularly in React applications, where UI state can be represented through these machines. Using state machines enables:
- simplified code
- reduced complexity
- straightforward async operations
Libraries like micro-machines facilitate implementing state machines while remaining framework-agnostic.
Here's an example:

In the example above, we see a machine with 3 states (State 1, State 2 and State 3) and 2 transitions (X and Y). We still need some more data to be able to run this machine, namely initial state and success states.
Let's say the initial state in the machine above is 'State 1', and the success state is 'State 3'. We can then execute the machine by imagining we put the finger on the initial state (State 1), and use the transitions to move our finger between states. So we could:
a) Start in 'State 1', use transition X to move to 'State 2'
b) Start in 'State 1', use transition Y to move to 'State 3'
Because we know 'State 3' is a success state, execution a) fails, and execution b) succeeds. We could have more than one success state. If both 'State 2' and 'State 3' were success states, then both executions would succeed.
Did you know?
According to computability theory, all languages (and by languages we mean sequence of characters, such as letters, spaces and symbols) matched by a Finite State Machine are also matched by regular expressions. So they are equally powerful, and equivalent. Everything a state machine matches can be also matched by a regular expression, and vice-versa. Actually, the languages matched by these artifacts are called Regular Languages.
A more broad group of languages are Context-Free Languages, which are languages matched by another construct called Context-Free Grammars. Many programming languages fit that classification, although most modern languages have fancy features that are context-aware.
Back to React
Because a (deterministic) state machine can only be at one state at a time, this fits React's model quite well, where the UI is the exact equivalent of executing a function on a given state. React's famous:
UI = f(state)
State can be any immutable data, such as the current state of a state machine. This might not seem like a big deal, but React is famously (or infamously?) known for being particularly annoying when it comes to handling async operations.
Modeling Sequential Processes
Imagine we have a React app up and running, and we have to create a button which will:
- Collect local data in an asynchronous way (eg: using IndexedDB)
- Operate on that data
- Send several API requests using that data
- Report the status for each step
Doing this directly in React would be quite cumbersome. We'd need to set up a couple state variables to keep the current step as well as related data (context) and a possible error. We'd also need an useEffect to look over all the data we need, all while stepping around async and await.
It could look something like this:
// Define the possible steps/states of the process
const STEPS = {
IDLE: 'idle',
FETCHING_DATA: 'fetching_data',
PROCESSING_DATA: 'processing_data',
SENDING_API_1: 'sending_api_1',
SENDING_API_2: 'sending_api_2',
COMPLETE: 'complete',
ERROR: 'error',
};
// Data needed as we run the process
interface Context {
someData: string;
moreInfo: number;
}
// State variables
const [currentStep, setCurrentStep] = useState(STEPS.IDLE);
const [context, setContext] = useState<Context>({ someData: '', moreInfo: 0 });
const [error, setError] = useState<Error | null>(null);
// useEffect hook to manage the asynchronous flow based on currentStep
useEffect(() => {
// We cannot use async/await directly so we nest it inside the useEffect
const executeStep = async () => {
switch (currentStep) {
case STEPS.FETCHING_DATA:
try {
const data = await fetchLocalData();
setContext({ someData: data }); // Store data for the next step
setCurrentStep(STEPS.PROCESSING_DATA); // Move to the next step on success
} catch (err) {
setError(err.message);
setCurrentStep(STEPS.ERROR); // Move to error state on failure
}
break;
case STEPS.PROCESSING_DATA:
try {
if (!context) throw new Error('No data available to process.');
const result = await processData({
someParameter: context.someField,
});
setContext(result); // Store processed data
setCurrentStep(STEPS.SENDING_API_1); // Move to the next step
} catch (err) {
setError(err.message);
setCurrentStep(STEPS.ERROR);
}
break;
// ... same for all steps
}
};
// Only execute steps if not idle
if (currentStep !== STEPS.IDLE) {
executeStep();
}
}, [currentStep, context, error, STEPS]);
Note that the code above is simplified and mostly pseudo-code. In an actual app all of it would be wrapped inside a custom hook, and it would also need some more infrastructure such as a start function to reset the state and kick off the process, but let's keep it simple for now.
Now let's compare it with a state machine using a minimal library we created called micro-machines:
type States = 'IDLE' | 'FETCHING_DATA' | 'PROCESSING_DATA'| 'SENDING_API_1' | 'SENDING_API_2' | 'COMPLETE'| 'ERROR'
interface Context {
someData: string;
moreInfo: number;
error?: Error;
}
const myMachine = () =>
createMachine<Context, States>((transition) => ({
context: {
someData: '',
moreInfo: 0,
},
initial: 'IDLE',
final: 'COMPLETE',
states: {
async FETCHING_DATA() {
try {
const data = await fetchLocalData();
await transition('PROCESSING_DATA', { someData: data });
} catch (err) {
await transition('ERROR', { error: err as unknown as Error });
}
},
async PROCESSING_DATA({ someData }) {
try {
// Ensure localData exists before processing
if (!someData) throw new Error('No data available to process.');
const result = await processData(someData);
await transition('SENDING_API_1', { moreInfo: result.someOtherField })
} catch (err) {
await transition('ERROR', { error: err as unknown as Error });
}
},
// ...
COMPLETE: undefined,
ERROR: undefined,
},
}));
Notice that this machine is framework agnostic. We simply create a machine with createMachine and use async/await as normal. We can then use the machine from React with:
const { start, state, context, success, terminated } = useMachine(myMachine);
The useMachine hook enables us to use the machine from React. It provides a start function, and ensures to re-render the component whenever the state and context changes. It also provides some extra utility state variables in success and terminated.
Advantages of using a State Machine
Some of the advantages of using a state machine instead of doing everything directly in React include:
- We can create and use machines without a framework, it's just JavaScript
- We can compose machines, one machine can call another and create more complex functionality
- Requires less code and is less complex, no need for
useEffectoruseState - Can use
async/awaitas usual
Conclusion
State Machines make modeling complex processes in React much more manageable, and is recommended whenever you need to keep track of some state as it goes through several steps. Especially if you know that behavior could be re-used across several pages or components.
Note that micro-machines is not the only library for state machines out there, but I found other libraries to have way too many features and be much more complex, when a simple state machine here and there might be all you need.
At the end of the day what's important to know is state machines the concept, not so much state machines the library :)