Flags like isLoading and isError can both be true at once. Learn to list your states and events, draw them, and build a state machine with useReducer.
In Part 7 we used one status that could hold one of four values. In Part 13 we chose one status over separate true-or-false values, and each status carried its own data. Part 13’s interview questions called that “a small state machine”. In Part 15 we moved state changes into a reducer.
This part puts those three ideas together. We’ll see what goes wrong with a set of true-or-false flags. Then we’ll list a screen’s states and the events that move between them, in a table and in a picture. Then we’ll build it with useReducer, so the code follows the picture.
Try this first
Here is a button that books a seat. It keeps three flags in state. A flag is a value that is only true or false. Read the code, but don’t press Run yet.
import { useState } from 'react'
// A fake server. The first request fails. Later ones work.
let requests = 0
function bookSeat(): Promise<number> {
requests = requests + 1
const n = requests
console.log('Server: request', n)
return new Promise((resolve, reject) => {
setTimeout(() => {
if (n === 1) {
reject(new Error('The server is busy'))
} else {
resolve(10 + n)
}
}, 800)
})
}
export default function App() {
const [isBooking, setIsBooking] = useState(false)
const [isError, setIsError] = useState(false)
const [isBooked, setIsBooked] = useState(false)
async function handleBook() {
setIsBooking(true)
try {
await bookSeat()
setIsBooked(true)
} catch {
setIsError(true)
}
setIsBooking(false)
}
return (
<div>
<button onClick={handleBook}>Book a seat</button>
{isBooking && <p>Booking...</p>}
{isError && <p>Something went wrong.</p>}
{isBooked && <p>Booked!</p>}
<p>
isBooking: {String(isBooking)}, isError: {String(isError)}, isBooked: {String(isBooked)}
</p>
</div>
)
}
Server: request 1
Server: request 2
bookSeat pretends to be a server. It waits 800 ms, the same way a real request takes time. Its first answer is an error. Its later answers are a seat number. The last line of the page shows the three flags, so you can watch them.
Make a guess. You click “Book a seat”, and it fails. You click it again. While the second request is out, what does the page say?
Now press Run, click, wait, and click again.
The page says “Booking…” and “Something went wrong.” at the same time. The flags line shows isBooking: true, isError: true. When the second answer comes, it gets worse. The page says “Something went wrong.” and “Booked!” together.
Here is one of the bugs. handleBook never sets isError back to false. It never sets isBooked back either. That is easy to forget, and nothing warned us.
There is more. Run it again and click the button twice, quickly. We tried that. The server got two requests, and the page ended with “Something went wrong.” and “Booked!” together.
Flags that can disagree
Each flag is true or false. Three flags give 2 × 2 × 2 = 8 mixes. Our button has only four real situations:
| What the user sees | isBooking |
isError |
isBooked |
|---|---|---|---|
| nothing has happened yet | false | false | false |
| waiting for the server | true | false | false |
| it failed | false | true | false |
| it worked | false | false | true |
The other four mixes have two or three flags set to true. None of them means anything. But the code can still reach them, as you just saw. We counted all 8 mixes in our test. Exactly 4 had more than one flag set to true.
Boolean is the type name for true and false. Each boolean flag changes on its own, and nothing keeps them in step.
React’s docs warn about this. On the page about state structure, they say flags like these leave “the door open for “impossible” states”. Their example is isSending and isSent both being true. Their fix is the one Part 13 used: one status that holds exactly one value.
You could fix the first bug by adding setIsError(false) and setIsBooked(false). But then every handler must remember every flag, forever. A better fix makes the bad mixes impossible to write. For that, we first list the real situations and how we move between them.
A state machine, in plain words
Think of a traffic light. It is always one color: green, yellow or red. Never two at once. When its timer runs out, it moves to the next color.
We can write the whole thing as a table:
| Now | What happens | Next |
|---|---|---|
| green | the timer runs out | yellow |
| yellow | the timer runs out | red |
| red | the timer runs out | green |
That table is a state machine. It has three parts:
- States: the list of situations it can be in. Here, green, yellow and red. It is in exactly one of them at a time.
- Events: things that happen. Here, “the timer runs out”.
- Transitions: the rules. Each row says: in this state, this event moves you to that state. To transition means to move from one state to another.
- Initial state: where it starts. Our light starts on green. Later in this part, XState writes this as
initial: 'idle'.
The list of states is fixed and has an end. So people say finite state machine. Finite means there is a fixed number of them.
One more rule is just as important. In the machines we build here, an event with no row for the current state does nothing. You stay where you are. XState does the same by default. Its docs say that when no transition is enabled, “the state will not change”.
React’s docs use the same idea. On the page “Reacting to Input with State”, they say: “In computer science, you may hear about a “state machine” being in one of several “states”.”
The booking button as a machine
Now our button. Its states are the four real situations from the table above. We’ll give them short names: idle, booking, booked and error.
Its events are the things that can happen:
book: the user clicks “Book a seat”.succeeded: the server answers with a seat.failed: the server answers with an error.retry: the user clicks “Try again”.reset: the user clicks “Book another”.
And the transitions:
| Now | Event | Next |
|---|---|---|
| idle | book | booking |
| booking | succeeded | booked |
| booking | failed | error |
| error | retry | booking |
| booked | reset | idle |
Every other pair does nothing. A book event while booking does nothing. A succeeded event while idle does nothing too.
Drawing the machine
A table is exact, but a picture is easier to follow. Draw each state as a box. Draw each transition as an arrow from one box to another, with the event’s name on it. React’s docs suggest the same: “try drawing each state on paper as a labeled circle, and each change between two states as an arrow.”
Here is the booking machine. Watch the highlight. It marks the state the machine is in now.
The booking machine. Boxes are states, arrows are events. Press play, or step through it.
Here are the same steps in words.
- The machine starts in
idle. - You click “Book a seat”. The
bookevent follows its arrow tobooking. - A second
bookevent comes. Nobookarrow leavesbooking, so nothing moves. (The booking app below never lets this happen. The tester app later in this part does.) - The server fails. The
failedevent moves the machine toerror. The error state holds the message, “Error: The server is busy”. - You click “Try again”. The
retryevent moves it back tobooking. - The server answers with seat 12. The
succeededevent moves it tobooked, and the state holds the seat.
Look at the picture once more. Can isBooking and isError both be true here? No. There is only one highlight. The machine is in one box at a time, so the mixed-up states from “Try this first” can’t happen.
An everyday example
Think of a board game. Your piece stands on one square. You draw a card, and the card says where to go from that square. If the card says nothing about your square, your piece stays where it is.
The squares are the states. The cards are the events. The rules on the cards are the transitions.
The exact version
A real machine also keeps some data. In the game, that is like a note in your hand. Our booked state holds a seat number, and our error state holds a message. A plain finite state machine has only its list of states. The data is extra. XState is a library we’ll meet later in this part. It keeps this data next to the state, in a part it calls context.
Building it with useReducer
Part 15 taught reducers: a function that takes the state and an action, and returns the next state. A state machine fits that shape well. People who work with state machines say event where Part 15 said action. It is the same kind of object. So in this part, “event” means the object you send. We’ll meet the word “action” again later, with another meaning.
First the types. They are discriminated unions, from Part 15. Each state has a status with its own fixed text. Each event has a type.
type BookingState =
| { status: 'idle' }
| { status: 'booking' }
| { status: 'booked'; seat: number }
| { status: 'error'; message: string }
type BookingEvent =
| { type: 'book' }
| { type: 'succeeded'; seat: number }
| { type: 'failed'; message: string }
| { type: 'retry' }
| { type: 'reset' }
Now the reducer. It asks two questions, in this order. First: which state am I in? Then: which event came? So there is a switch on state.status, and inside each case a switch on event.type:
type BookingState =
| { status: 'idle' }
| { status: 'booking' }
| { status: 'booked'; seat: number }
| { status: 'error'; message: string }
type BookingEvent =
| { type: 'book' }
| { type: 'succeeded'; seat: number }
| { type: 'failed'; message: string }
| { type: 'retry' }
| { type: 'reset' }
function bookingMachine(state: BookingState, event: BookingEvent): BookingState {
switch (state.status) {
case 'idle': {
switch (event.type) {
case 'book':
return { status: 'booking' }
default:
return state
}
}
case 'booking': {
switch (event.type) {
case 'succeeded':
return { status: 'booked', seat: event.seat }
case 'failed':
return { status: 'error', message: event.message }
default:
return state
}
}
case 'booked': {
switch (event.type) {
case 'reset':
return { status: 'idle' }
default:
return state
}
}
case 'error': {
switch (event.type) {
case 'retry':
return { status: 'booking' }
default:
return state
}
}
}
}
Put it next to the table. Each row of the table is one inner case. Every default: return state is the rule “if there is no row, nothing happens”.
The order of the two questions matters. The reducers in Part 15 switched on the action first. Here the state comes first, and book appears only inside case 'idle'. So the state decides which events count.
The app
Here is the whole app. It uses the machine with useReducer.
import { useReducer } from 'react'
type BookingState =
| { status: 'idle' }
| { status: 'booking' }
| { status: 'booked'; seat: number }
| { status: 'error'; message: string }
type BookingEvent =
| { type: 'book' }
| { type: 'succeeded'; seat: number }
| { type: 'failed'; message: string }
| { type: 'retry' }
| { type: 'reset' }
function bookingMachine(state: BookingState, event: BookingEvent): BookingState {
switch (state.status) {
case 'idle': {
switch (event.type) {
case 'book':
return { status: 'booking' }
default:
return state
}
}
case 'booking': {
switch (event.type) {
case 'succeeded':
return { status: 'booked', seat: event.seat }
case 'failed':
return { status: 'error', message: event.message }
default:
return state
}
}
case 'booked': {
switch (event.type) {
case 'reset':
return { status: 'idle' }
default:
return state
}
}
case 'error': {
switch (event.type) {
case 'retry':
return { status: 'booking' }
default:
return state
}
}
}
}
// A fake server. The first request fails. Later ones work.
let requests = 0
function bookSeat(): Promise<number> {
requests = requests + 1
const n = requests
console.log('Server: request', n)
return new Promise((resolve, reject) => {
setTimeout(() => {
if (n === 1) {
reject(new Error('The server is busy'))
} else {
resolve(10 + n)
}
}, 800)
})
}
export default function App() {
const [state, send] = useReducer(bookingMachine, { status: 'idle' })
function startBooking(event: BookingEvent) {
send(event)
bookSeat().then(
seat => send({ type: 'succeeded', seat }),
error => send({ type: 'failed', message: String(error) }),
)
}
switch (state.status) {
case 'idle':
return <button onClick={() => startBooking({ type: 'book' })}>Book a seat</button>
case 'booking':
return <p>Booking...</p>
case 'booked':
return (
<div>
<p>Seat {state.seat} is yours.</p>
<button onClick={() => send({ type: 'reset' })}>Book another</button>
</div>
)
case 'error':
return (
<div>
<p>{state.message}</p>
<button onClick={() => startBooking({ type: 'retry' })}>Try again</button>
</div>
)
}
}
Server: request 1
Server: request 2
Run it. Click “Book a seat”. You see “Booking…”, then “Error: The server is busy” with a “Try again” button. Click it. After “Booking…” again, the page says “Seat 12 is yours.”
A few things to notice:
- We named
dispatchassend. It is the same function from Part 15. The name just says what it does: it sends an event to the machine. - The two answers from the server become events too:
succeededwith the seat, orfailedwith the message..then(a, b)runsawhen the promise works andbwhen it fails. - The page is one
switchonstate.status. Each state shows its own screen. This is Part 7‘s idea: work out the page from the state.
The Console shows Server: request 1 and Server: request 2, one line for each click. Strict Mode doesn’t double them. React never calls a click handler twice. Strict Mode does call the reducer twice for each event, but the reducer is pure, so you never see it.
Events that don’t fit are ignored
The machine has no arrow for book while booking. So what does it do with one? Let’s send it events by hand. This app has one button for each event:
import { useReducer } from 'react'
type BookingState =
| { status: 'idle' }
| { status: 'booking' }
| { status: 'booked'; seat: number }
| { status: 'error'; message: string }
type BookingEvent =
| { type: 'book' }
| { type: 'succeeded'; seat: number }
| { type: 'failed'; message: string }
| { type: 'retry' }
| { type: 'reset' }
function bookingMachine(state: BookingState, event: BookingEvent): BookingState {
switch (state.status) {
case 'idle': {
switch (event.type) {
case 'book':
return { status: 'booking' }
default:
return state
}
}
case 'booking': {
switch (event.type) {
case 'succeeded':
return { status: 'booked', seat: event.seat }
case 'failed':
return { status: 'error', message: event.message }
default:
return state
}
}
case 'booked': {
switch (event.type) {
case 'reset':
return { status: 'idle' }
default:
return state
}
}
case 'error': {
switch (event.type) {
case 'retry':
return { status: 'booking' }
default:
return state
}
}
}
}
const events: BookingEvent[] = [
{ type: 'book' },
{ type: 'succeeded', seat: 7 },
{ type: 'failed', message: 'Busy' },
{ type: 'retry' },
{ type: 'reset' },
]
export default function App() {
const [state, send] = useReducer(bookingMachine, { status: 'idle' })
return (
<div>
<p>State: {JSON.stringify(state)}</p>
{events.map(event => (
<button key={event.type} onClick={() => send(event)}>
{event.type}
</button>
))}
</div>
)
}
JSON.stringify(state) turns the state object into text, so you can see all of it.
Run it. Click book twice. The first click moves the state to booking. The second does nothing: the state stays {"status":"booking"}. Click retry now: nothing again. Click failed, and the state becomes an error with its message. Now click succeeded. Nothing. A late success can’t get into the error state.
We checked one more thing. For an ignored event, our reducer returns the same state object it was given. With Strict Mode off, React then called App once and changed nothing on the page. As Part 15 explained, React compares the old and new state with Object.is. The same object means “no change”.
The machine can’t stop your handler
Ignoring an event protects the state. It does not stop other code in your handler. Here the “Book a seat” button stays on the page in every state. Click it twice, quickly:
import { useReducer } from 'react'
type BookingState =
| { status: 'idle' }
| { status: 'booking' }
| { status: 'booked'; seat: number }
| { status: 'error'; message: string }
type BookingEvent =
| { type: 'book' }
| { type: 'succeeded'; seat: number }
| { type: 'failed'; message: string }
| { type: 'retry' }
| { type: 'reset' }
function bookingMachine(state: BookingState, event: BookingEvent): BookingState {
switch (state.status) {
case 'idle': {
switch (event.type) {
case 'book':
return { status: 'booking' }
default:
return state
}
}
case 'booking': {
switch (event.type) {
case 'succeeded':
return { status: 'booked', seat: event.seat }
case 'failed':
return { status: 'error', message: event.message }
default:
return state
}
}
case 'booked': {
switch (event.type) {
case 'reset':
return { status: 'idle' }
default:
return state
}
}
case 'error': {
switch (event.type) {
case 'retry':
return { status: 'booking' }
default:
return state
}
}
}
}
// A fake server. The first request fails. Later ones work.
let requests = 0
function bookSeat(): Promise<number> {
requests = requests + 1
const n = requests
console.log('Server: request', n)
return new Promise((resolve, reject) => {
setTimeout(() => {
if (n === 1) {
reject(new Error('The server is busy'))
} else {
resolve(10 + n)
}
}, 800)
})
}
export default function App() {
const [state, send] = useReducer(bookingMachine, { status: 'idle' })
function startBooking(event: BookingEvent) {
send(event)
bookSeat().then(
seat => send({ type: 'succeeded', seat }),
error => send({ type: 'failed', message: String(error) }),
)
}
return (
<div>
<button onClick={() => startBooking({ type: 'book' })}>Book a seat</button>
<p>State: {JSON.stringify(state)}</p>
</div>
)
}
Server: request 1
Server: request 2
The state ignored the second book. But startBooking still called the server, so the server got two requests. Then the answers came. The first failed, and the state moved to error. The second was a success with seat 12. It came while the state was error, so the machine ignored it. We logged both answers to be sure: failed came first, then succeeded with seat 12.
So the server booked seat 12 for you, and the page says it failed. That is a real bug.
There are two fixes, and you can use both.
Fix 1: don’t show buttons for events the state ignores. The booking app in “The app” above did this. In booking, it shows only “Booking…”. There is no button to click twice. We checked: in the booking state, that app had 0 buttons on the page.
Fix 2: ask the machine before you act. The machine is a plain function, so the handler can call it. If the answer is the same state, the event would be ignored. Then skip the side effect too. This works because our machine returns the very same object for an ignored event, so === can tell. And state is this render’s snapshot, from Part 4. React renders again after the first click, so the second click’s handler reads booking:
type BookingState = { status: 'idle' } | { status: 'booking' }
type BookingEvent = { type: 'book' }
declare function bookingMachine(state: BookingState, event: BookingEvent): BookingState
declare function bookSeat(): Promise<number>
declare const state: BookingState
declare function send(event: BookingEvent): void
function startBooking(event: BookingEvent) {
if (bookingMachine(state, event) === state) {
return
}
send(event)
bookSeat()
}
declare tells TypeScript that these things exist, as in Part 15. It keeps the piece short. In the full app, startBooking gets these three lines at the top, and the rest stays the same. We tried that: two quick clicks sent one request, not two.
States that carry data
Look at the booked state: { status: 'booked'; seat: number }. The seat number lives inside the state that needs it. There is no seat anywhere else. So “booked, but no seat” can’t be written, and neither can “error, with a seat”.
TypeScript checks this. Try to read the seat in the error state:
type BookingState =
| { status: 'idle' }
| { status: 'booking' }
| { status: 'booked'; seat: number }
| { status: 'error'; message: string }
function Message({ state }: { state: BookingState }) {
if (state.status === 'error') {
return <p>Seat {state.seat}</p>
}
return <p>{state.status}</p>
}
TypeScript stops you: Property 'seat' does not exist on type '{ status: "error"; message: string; }'. After the check state.status === 'error', it knows exactly which state you have. That is the narrowing from Part 7 and Part 13. Change the check to state.status === 'booked', and the error goes away. We checked both.
It also checks the other way. We wrote { status: 'booked' } with no seat, and TypeScript said Property 'seat' is missing. The playground doesn’t check types, so you see these errors in an editor like VS Code.
Compare this with “Try this first”. There, nothing stopped isBooked from being true while isError was true. Here, the types themselves have no room for that.
Work out the page from the state
You may want a flag like isBusy, to turn a button off while booking. Don’t keep it in state. Work it out while rendering:
type BookingState =
| { status: 'idle' }
| { status: 'booking' }
| { status: 'booked'; seat: number }
| { status: 'error'; message: string }
function BookButton({ state, onBook }: { state: BookingState; onBook: () => void }) {
const isBusy = state.status === 'booking'
return (
<button disabled={isBusy} onClick={onBook}>
{isBusy ? 'Booking...' : 'Book a seat'}
</button>
)
}
isBusy is a normal variable, not state. It is made fresh on every render, from the one value we keep, state.status. So it can never disagree with it.
This is the rule from Part 11. If you can work a value out while rendering, don’t keep it in state. React’s docs give the same advice: don’t keep pieces of state that can disagree.
Guards and actions
Two more words come up with state machines.
A guard is a condition on a transition. The arrow is only followed if the condition is true. Say a booking may be tried at most 3 times. The error state keeps a count of tries, and retry checks it:
type RetryState =
| { status: 'booking'; tries: number }
| { status: 'error'; message: string; tries: number }
type RetryEvent = { type: 'failed'; message: string } | { type: 'retry' }
function retryMachine(state: RetryState, event: RetryEvent): RetryState {
switch (state.status) {
case 'booking': {
switch (event.type) {
case 'failed':
return { status: 'error', message: event.message, tries: state.tries }
default:
return state
}
}
case 'error': {
switch (event.type) {
case 'retry':
if (state.tries >= 3) {
return state
}
return { status: 'booking', tries: state.tries + 1 }
default:
return state
}
}
}
}
The if (state.tries >= 3) line is the guard. We called this machine by hand, starting from booking with tries: 1. After three failures, tries was 3, and the next retry returned the same error state. A guard only reads values and gives back yes or no. So it is pure, and it is fine inside the reducer. XState’s docs say the same: guards should be pure, and should answer at once.
An action, in machine words, is something the machine does when it moves. Starting a timer or writing a log line are examples. This is not Part 15’s action, which we call an event here. It is also not React 19’s form Actions, which Part 29 covers. XState’s docs say “Actions are fire-and-forget effects.” Fire-and-forget means you start it and don’t wait for an answer.
Calling the server is different, because its answer must come back. In our app, the answer comes back as an event, succeeded or failed. XState has a separate tool for this. A state can start a promise and wait for its answer. Its docs say that if the state is left before the promise ends, the result is thrown away.
Actions don’t belong inside our reducer. The reducer must be pure, as Part 15 showed, and Strict Mode calls it twice. Put actions in one of two places:
- In the event handler, when a person caused them. Our
startBookingcalls the server right after it sendsbook. React’s docs give the rule: “If this logic is caused by a particular interaction, keep it in the event handler.” - In an Effect, when being in a state should start something. A timer that runs while a light is green is like this. We’ll build one in the next section.
A transition table instead of a switch
When states carry no data, you can write the table as an object. Here is the traffic light, with two extra events. broke makes the light flash yellow. fixed sets it back to red.
import { useEffect, useReducer } from 'react'
type Light = 'green' | 'yellow' | 'red' | 'flashing'
type LightEvent = 'timer' | 'broke' | 'fixed'
const table: Record<Light, Partial<Record<LightEvent, Light>>> = {
green: { timer: 'yellow', broke: 'flashing' },
yellow: { timer: 'red', broke: 'flashing' },
red: { timer: 'green', broke: 'flashing' },
flashing: { fixed: 'red' },
}
function lightMachine(light: Light, event: LightEvent): Light {
return table[light][event] ?? light
}
const waitMs: Record<Light, number> = { green: 2000, yellow: 1000, red: 2000, flashing: 1000 }
export default function App() {
const [light, send] = useReducer(lightMachine, 'green')
useEffect(() => {
console.log('Timer set for', light)
const id = setTimeout(() => send('timer'), waitMs[light])
return () => clearTimeout(id)
}, [light])
return (
<div>
<p>Light: {light}</p>
<button onClick={() => send('broke')}>Break it</button>
<button onClick={() => send('fixed')}>Fix it</button>
</div>
)
}
Timer set for green
Timer set for green
Timer set for yellow
Timer set for red
Timer set for flashing
Timer set for red
Here the events are plain strings, like 'timer', because they carry no data. Some new pieces:
table[light]is the row for the current light.table[light][event]is the next light, if that row has the event.Partialmeans some keys may be missing. A missing key is a pair with no arrow.??gives the right side when the left side isundefinedornull. Sotable[light][event] ?? lightmeans: “the next light, or stay where you are”.
Run it. The light goes green, yellow, red. Click “Break it”: it flashes. Wait as long as you like. It keeps flashing, because the flashing row has no timer. Click “Fix it”, and it goes to red and starts again.
The timer is an action, so it lives in an Effect. Each time light changes, the Effect sets one timer, and the cleanup clears the old one (Part 12). The second line comes from Strict Mode. When the component mounts, it runs setup, cleanup, and setup again (Part 11). After that, each new light logs once. In flashing, the timer still fires. The machine ignores it, so the state stays the same and the Effect doesn’t run again.
TypeScript checks this table too. Record<Light, ...> needs one row for every light. We deleted the flashing row, and TypeScript said Property 'flashing' is missing. If you remove Partial, every row must list every event. Then you have to write each “do nothing” pair as well.
Table or switch? A table is short, and it reads like the drawing. It works best when states are plain names. When states carry data, like a seat number, the switch is easier. Each case can build the next state with its data.
When a state machine is worth it
A state machine is worth it when:
- a part of the page goes through steps, like a form that is sent, or a request that loads;
- some actions should only work at some steps, like “Try again” only after an error;
- you keep finding bugs where two flags are
truetogether; - you want a picture of the behavior that others on your team can check.
It is too much when:
- there is one flag, like a menu that is open or closed.
useState(false)is a machine with two states already. Writing a reducer for it adds code and nothing else. - the values have nothing to do with each other, like a name and an age in a form.
A good first step costs almost nothing. Replace a group of flags with one status, as in Part 7. Move to a reducer machine when events start to need rules.
Libraries: XState and statecharts
You don’t need a library for the machines in this part. But you may meet one in bigger apps. One of them is XState. It is a library for state machines and statecharts, for JavaScript and TypeScript apps. It is on npm as xstate. It is at version 5 (5.33.2 on 7 October 2026). The @xstate/react package gives you a useMachine hook.
An XState machine is written as an object. Here is the shape of our booking machine, in the style of XState’s docs:
const bookingConfig = {
id: 'booking',
initial: 'idle',
states: {
idle: { on: { book: 'booking' } },
booking: { on: { succeeded: 'booked', failed: 'error' } },
booked: { on: { reset: 'idle' } },
error: { on: { retry: 'booking' } },
},
}
That is our table again: each state lists the events it handles in on. In XState you pass an object like this to createMachine. The playground can only load React, so we won’t run XState here.
XState also uses a bigger idea, the statechart. A statechart is a state machine with extra powers. XState’s docs list three things that statecharts add to plain machines. In plain words, a state can have smaller states inside it. Two parts of a machine can run at the same time. And machines can send events to each other. David Harel described statecharts in a paper from 1987.
Common mistakes
A group of flags for one thing
isLoading, isError and isSuccess describe one request. Three flags give 8 mixes for 4 real situations. You saw two flags true at once in “Try this first”. Use one status, or a machine.
A quieter form of the same mistake is a status plus a separate error state. Then status can be 'success' while error still holds an old message. React’s docs give this exact example: “a non-null error doesn’t make sense when status is ‘success'”. Put the message inside the error state, as we did.
Side effects inside the machine
import { useReducer } from 'react'
type State = { status: 'idle' } | { status: 'booking' }
type BookingEvent = { type: 'book' }
// A fake server. It only counts and logs.
let requests = 0
function bookSeat() {
requests = requests + 1
console.log('Server: request', requests)
}
function machine(state: State, event: BookingEvent): State {
switch (state.status) {
case 'idle': {
switch (event.type) {
case 'book':
bookSeat()
return { status: 'booking' }
default:
return state
}
}
case 'booking': {
return state
}
}
}
export default function App() {
const [state, send] = useReducer(machine, { status: 'idle' })
return (
<div>
<button onClick={() => send({ type: 'book' })}>Book a seat</button>
<p>State: {state.status}</p>
</div>
)
}
Server: request 1
Server: request 2
It seems natural to call the server right where the transition happens. Run it and click once. The server gets two requests. Strict Mode called the reducer twice. With Strict Mode off, we saw one request. But the bug is still there. The reducer is not pure, and React’s rules say it must be.
The fix, as in Part 15: return only the next state from the reducer. Call the server in the event handler, as startBooking does, or in an Effect.
Forgetting an event in a state
Say the error case forgot its retry row:
type BookingState = { status: 'error'; message: string }
type BookingEvent = { type: 'retry' }
function errorCase(state: BookingState, event: BookingEvent): BookingState {
switch (event.type) {
default:
return state
}
}
Nothing warns you. The default quietly ignores retry. We tried it in the booking app. After the error, “Try again” called the server again, but the page stayed on the error. The machine had no way out of error. A state with no way out is often called a dead end.
TypeScript doesn’t catch this one. The inner default turns off the never check from Part 15, as Part 7 warned. A missing state is different. We removed the whole case 'error', and TypeScript said Function lacks ending return statement. A missing event row gets no error, because “ignore it” is a valid answer.
So test the machine itself. A reducer is a plain function, so you can call it for every state and every event, as in Part 15:
type BookingState =
| { status: 'idle' }
| { status: 'booking' }
| { status: 'booked'; seat: number }
| { status: 'error'; message: string }
type BookingEvent =
| { type: 'book' }
| { type: 'succeeded'; seat: number }
| { type: 'failed'; message: string }
| { type: 'retry' }
| { type: 'reset' }
function bookingMachine(state: BookingState, event: BookingEvent): BookingState {
switch (state.status) {
case 'idle': {
switch (event.type) {
case 'book':
return { status: 'booking' }
default:
return state
}
}
case 'booking': {
switch (event.type) {
case 'succeeded':
return { status: 'booked', seat: event.seat }
case 'failed':
return { status: 'error', message: event.message }
default:
return state
}
}
case 'booked': {
switch (event.type) {
case 'reset':
return { status: 'idle' }
default:
return state
}
}
case 'error': {
switch (event.type) {
case 'retry':
return { status: 'booking' }
default:
return state
}
}
}
}
const states: BookingState[] = [
{ status: 'idle' },
{ status: 'booking' },
{ status: 'booked', seat: 7 },
{ status: 'error', message: 'Busy' },
]
const events: BookingEvent[] = [
{ type: 'book' },
{ type: 'succeeded', seat: 7 },
{ type: 'failed', message: 'Busy' },
{ type: 'retry' },
{ type: 'reset' },
]
const moves: string[] = []
let ignored = 0
for (const state of states) {
for (const event of events) {
const next = bookingMachine(state, event)
if (next === state) {
ignored = ignored + 1
} else {
moves.push(state.status + ' + ' + event.type + ' -> ' + next.status)
}
}
}
export default function App() {
return (
<div>
<ul>
{moves.map(move => (
<li key={move}>{move}</li>
))}
</ul>
<p>Ignored: {ignored} pairs</p>
</div>
)
}
Run it. It prints the 5 moves of the machine, the same 5 rows as our table. The other 15 of the 20 pairs are ignored. Compare the list with your drawing. Every arrow should be there, and nothing else. If error + retry were missing, you’d see 4 moves, and no line starting with error.
A state nothing can reach
The opposite mistake is a state with no arrow coming in. Say you add { status: 'cancelled' } to the union and give it a case in the reducer. But no event ever returns { status: 'cancelled' }. The code type-checks, and that state can never happen.
We tested this. We started at idle and followed every arrow we could. We reached idle, booking, booked and error, but never cancelled. In a drawing, it’s easy to see: no arrow points at the box. Either add the event that leads there, or delete the state.
Showing a button the state can’t use
A “Book a seat” button that stays on the page while booking lets the user click again. You saw where that leads: two requests and a lost seat. Show only the buttons that send an event the current state accepts.
Practice
Press Edit on the examples above and try these.
- In the app with one button for each event, click
succeededfirst. Then clickbook, andbookagain. What does “State” show after each click? - In the booking app, add a “Cancel” button while booking. Add the event
{ type: 'cancel' }and a row:booking+cancelgoes toidle. Click “Book a seat”, then “Cancel”. What happens when the server’s answer comes later? - In the traffic light, add a state
'off'and two events,'off'and'on'. Any color, or flashing, goes tooffwith'off'.offgoes toredwith'on'. Add two buttons. What does TypeScript ask you to add towaitMs? - Find the example that prints every move, under “Forgetting an event in a state”. Delete the
case 'retry':line and thereturnunder it. How many moves and ignored pairs do you get? Which state has no way out now?
Answers
{"status":"idle"}, then{"status":"booking"}, then{"status":"booking"}again.succeededhas no row foridle, andbookhas no row forbooking. Both are ignored.- Add
| { type: 'cancel' }toBookingEvent. Incase 'booking', addcase 'cancel': return { status: 'idle' }. InApp, thebookingscreen gets a button:<button onClick={() => send({ type: 'cancel' })}>Cancel</button>, next to<p>Booking...</p>, inside a<div>. After “Cancel”, the page shows “Book a seat” again. When the answer comes, nothing changes. The answer arrives asfailed, andidlehas no row for it. The Console still showsServer: request 1, because the request had already gone out. Book again and cancel again, and the same happens to a success. Cancel only changes the page. The server may still book the seat, so a real app would also tell the server. One case still goes wrong. Click “Book a seat”, “Cancel” and “Book a seat” again, all within 800 ms. We tried it. The first answer,failed, came while the machine was inbookingfor the second request. The machine took it, and the page showed the error, even though the second request worked. The machine can’t tell old answers from new ones. Give each request a number, keep it in thebookingstate, and put it on thesucceededandfailedevents. Then the machine ignores answers with the wrong number. We tried that too, and the page showed seat 12. This is the same problem as theignoreflag in Part 13. waitMsisRecord<Light, number>, so TypeScript saysProperty 'off' is missing, in an editor like VS Code. The playground won’t show it. If you run it without the row,waitMs['off']isundefined. We tried it: the timer fired at once, the machine ignored it, and the light stayed off. Addoff: 1000(any number works). In the table, addoff: 'off'to the four other rows, and a new rowoff: { on: 'red' }. Add<button onClick={() => send('off')}>Turn off</button>and the same for'on'. When it’s off, the timer fires once and is ignored, so the light stays off. “Turn on” goes to red, and about 2 seconds later to green.- 4 moves and 16 ignored pairs. No line starts with
error, soerrorhas no way out: a dead end.
Interview questions
Try to answer each one out loud before you open the answer.
What is a finite state machine, in UI terms?
It has a fixed list of states and a list of events. Its rules say which event moves you from which state to which. The UI is in exactly one state at a time. An event with no rule for the current state is ignored. For a request, the states might be idle, loading, success and error.
A strong answer draws it: boxes for states, arrows for events. It also says the drawing and the code should match, so others can check the behavior without reading code.
What is wrong with keeping a separate true-or-false flag for loading, error and success?
They can disagree. Three flags allow 8 mixes, but only 4 make sense. A handler that forgets to reset one flag can show “Loading” and “Error” together. In our test, a retry after an error showed both.
The fix is one status with one value at a time. A strong answer adds one more step. Data like the error message should live inside the state that needs it, as a discriminated union. Then “success with an old error” can’t be written. React’s docs say to avoid state whose pieces can disagree.
How do you build a state machine in React without a library?
With useReducer. The state is a discriminated union, with a status field. The events are a union too, with a type field. The reducer switches on state.status first, then on event.type. Each state handles only its own events, and default: return state ignores the rest. The component renders a different screen for each status.
A strong answer mentions a transition table, Record<State, Partial<Record<Event, State>>>, for machines whose states carry no data. It also says the reducer is a plain function, so you can test every state and event pair without React.
Where do side effects go in a reducer-based state machine?
Not in the reducer. A reducer must be pure, and Strict Mode calls it twice in development. A server call in the reducer was sent twice in our test. Put a side effect in the event handler when a user caused it. Put it in an Effect when being in a state should start it, like a timer.
A strong answer notes that ignoring an event in the reducer does not stop the handler. If the handler calls the server anyway, you get a request the state never knew about. Hide the button, or ask the machine first: if (machine(state, event) === state) return.
What is a guard?
A condition on a transition. The event moves the machine only if the condition is true. For example, retry works only while tries is under 3. A guard just reads values and says yes or no, so it is pure and can live in the reducer.
A strong answer contrasts it with an action, something the machine does when it moves, like starting a timer. Guards decide. Actions do things. This “action” is not the object you dispatch to useReducer, and not a React 19 form Action.
When is a state machine too much?
When there is one simple flag, like a menu that is open or closed. useState(false) is already a two-state machine. It is also too much for values that don’t depend on each other. It helps when a feature goes through steps. It also helps when some actions are allowed only at some steps, or when flags keep disagreeing.
A strong answer gives a middle step: replace the flags with one status first. Add a reducer machine once events need rules.
What is a statechart, and when would you use XState?
A statechart is a state machine with extra powers. XState’s docs list three extras. A state can have states inside it. Parts can run at the same time. And machines can talk to each other. David Harel described statecharts in 1987.
XState is a library for state machines and statecharts, with useMachine for React. You might use XState when one flow has many states and steps inside steps. For a form with four states, useReducer is enough.
Sources
- Reacting to Input with State, react.dev: the “state machine” sentence, drawing states as circles and changes as arrows, two booleans that allow an “impossible” mix, and “a non-null error doesn’t make sense when status is ‘success'”.
- Choosing the State Structure, react.dev: avoid state whose pieces can disagree, and
isSendingandisSentleaving “the door open for “impossible” states”. - Extracting State Logic into a Reducer, react.dev: reducers must be pure.
- useReducer, react.dev:
dispatch,Object.iscomparison, and Strict Mode calling the reducer twice. - You Might Not Need an Effect, react.dev: “If this logic is caused by a particular interaction, keep it in the event handler.”
- Narrowing, TypeScript Handbook: discriminated unions.
- What is XState?, State machines and statecharts, Events and transitions, Guards, Actions and @xstate/react, Stately docs: what XState is, statecharts adding “hierarchy, concurrency and communication”, “the state will not change” when no transition is enabled, the
onshape, “Guards should be pure, synchronous functions”, “Actions are fire-and-forget effects”, anduseMachine. The version, 5.33.2, is fromnpm view xstateon 7 October 2026. Invoke: the result of a promise started by a state is thrown away when that state is left. - David Harel, “Statecharts: a visual formalism for complex systems”, Science of Computer Programming 8(3), 1987, pages 231–274.
- The page text, the two flags
trueat once, the 8 and 4 mixes, the 5 moves and 15 ignored pairs, the request counts, the lost seat 12, the guard’s tries, the unreachablecancelledstate, the timer logs and the TypeScript errors above come from running React 19.3.0 and TypeScript 7.0.2 for this post. - This part follows the State Machines kata in react-katas.