I was working as a frontend developer at an event platform. One day, a task came up to redesign the ticket purchasing flow, where the user, after selecting an event, would follow a series of steps to choose the session, section, and desired seat for their ticket.
The flow had to follow a specific logic, allowing the user at each step to either go back to the previous step or, after validating the data, advance to the next one. At the end of the flow, the stored information would be sent to the backend, which would handle the purchase processing.
If you’ve ever had to implement something similar, like an onboarding flow, for example, you’ve probably worked with the concept of a state machine, even if informally. In this post, we’ll see how a state machine, or more specifically, a finite and deterministic one, can help us solve many everyday problems. Beyond the theory, we’ll implement an onboarding flow from scratch using TypeScript and React to illustrate the concept. Let’s hop into it.
Models of Computation
First of all, let’s take a moment to reflect. Do you know how to define a computer? I think the first thing that comes to mind is an image of a laptop, desktop, or smartphone. All these answers are correct; these items are indeed computers, computers based on a "modern computer model."
What does that mean? If you take the verb to compute and add the suffix -er, you get computer. In both English and Portuguese (my native language), adding a specific suffix to the end of a verb can create a noun that indicates an agent or something that performs an action.
In that sense, a computer would simply be something that can compute, something that can perform computations. Generally, the verb to compute is used as a synonym for to calculate, but a definition I like is that to compute means to receive an input, perform a calculation (whether arithmetic or not), and produce an output.
Considering this, we’ve had computers for a long time. Humans, for example, were used as computers, as well-illustrated in the 2016 film Hidden Figures (I recommend you watch it). Computing machines were conceived, and some even built, centuries before our own, such as the Antikythera mechanism, dated to around 87 BC.
Image 1: A conceptual illustration of the mechanism. It is believed to have been used for astronomical calculations. Image extracted from this post.

However, even though the definition of a computer we’ve adopted fits both humans and the Antikythera mechanism, neither is considered a computer in the informal sense.
Why does this happen? To answer that question, we have to discuss a fundamental concept: models of computation. In my research, I found some good definitions for what a model of computation is, but they can all feel a bit strange for being too abstract.
Even so, we can’t escape abstraction in a case like this. We use the concept of a model of computation to describe the behavior of something (commonly an abstract machine or a theoretical model) that can compute, as well as to describe how it performs its computations.
When we talk informally about computers, which I referred to above as a "modern computer model," it’s almost certain we’re thinking of machines that are the result of the work of people like Alan Turing and John von Neumann. In fact, the device you’re using to read this post is a product of a model of computation called the Turing Machine, conceived by Alan Turing in 1936.
Image 2: A physical Turing machine constructed by Mike Davey. It’s important to note that every physical device is limited, whereas Turing Machines are infinite in theory.
![]()
A simple computer
A Deterministic Finite State Machine (DFSM) is also a model of computation, but it’s simpler and less powerful than a Turing Machine. Also called a Deterministic Finite Automaton (DFA), a DFSM is a machine that reads a set of symbols and, for each symbol read, responds by making a transition. This transition moves from the machine’s current state to another state, or even back to the same state.
Implementations of DFSMs are abundant in our daily lives. Traffic lights, elevators, regular expressions, and combination locks are examples of things that can be represented by DFSMs.
However, it’s important to note that while it’s possible to build a machine, whether analog or digital, that is an implementation of a DFSM, it doesn’t mean all the examples listed above are built that way. Precisely because they are simpler, FSMs can be simulated by Turing Machines, which, in practice, allows our laptops, smartphones, etc., to be used for this purpose.
Now that we know what a DFSM is, let’s try to visualize one conceptually. Imagine that you are working at an e-commerce company. Your company needs a clear, precise way to represent the lifecycle of a given order, and you’ve been asked to create a diagram for it. After thinking for a while, this is the result:
Image 3: A DFSM diagram representing the order status, its possible states and transitions.

Since an online order can have five distinct statuses:PendingPayment (PP), Processing (P), Shipped (S), Delivered (D), and Cancelled (C)
Each status is represented by a circle (a state in this case). An order’s journey ends when it is either successfully delivered or cancelled. Therefore, Delivered and Cancelled are our accept states. In state machine diagrams, this is typically shown by drawing a double circle.
Keep in mind that the real power of a state machine is in defining and restricting movement between states. An order can’t go from PendingPayment straight to Delivered, for example. It must follow a specific path.
For the transitions between statuses, you chose a simple set of symbols: {0, 1}. You can think of 1 as a "success" signal that moves the process forward, and 0 as a "failure" or "cancellation" signal. So:
When an order is in the
PendingPaymentstate, if the payment goes through1, it transitions to theProcessingstate. If the payment fails or is declined (input0), it moves toCancelled.Now, while in the
Processingstate, if the order is packed and sent for delivery (input1), it transitions toShipped. If the item is out of stock or the user cancels the order (input0), it transitions toCancelled.This pattern continues: from the
Shippedstate, a successful delivery (input1) moves the order to theDeliveredstate. If the package is lost in transit (input0), it transitions toCancelled.
But, what if we input 1 in Delivered or 0 in Cancelled? This question highlights a key rule of DFSMs: for a machine to be truly deterministic, it must have a defined path for every possible input from every state.
Our diagram simplifies this visually, but you can imagine that for final states, any further input results in a self-loop transition, where the state simply points back to itself.
Think of it as a rule that says, "once you’re here, you’re done." This ensures there are no dead ends or undefined behaviors, fulfilling the strict requirements of a deterministic machine.
Finally, when a customer places an order, it must begin in a specific state. To represent this, you drew a black arrow pointing to PendingPayment. This is the order’s initial state. Can you see how this model provides a clear structure for our status flow?
Formal Definition of a DFA/DFSM
Let’s use our diagram to formalize the concept of a DFA/DFSM. Formalizing concepts is very useful because we can not only express them concisely but also explore their limits abstractly.
Note that from the diagram, we have a finite set of possible states our order can be in: {PendingPayment, Processing, Shipped, Delivered, Cancelled}, as well as a set of input symbols {0, 1} that trigger transitions. Additionally, we have the transitions themselves, which define the path an order can take.
Therefore, let M be a Deterministic Finite Automaton (DFA), or a Deterministic Finite State Machine (DFSM). M is described by a 5-tuple (Q, Σ, δ, q₀, F) such that:
Q: Is a finite set of states.
Σ (Sigma): Is a finite set of input symbols, also called the alphabet. These are the symbols the machine can recognize.
δ (delta): Is the transition function, which takes a state and an input symbol and returns the next state (mathematically,
δ: Q × Σ → Q).q₀: Is the initial state, which is one of the states in Q (
q₀ ∈ Q).F: Is the set of accept states or final states, which is a subset of Q (
F ⊆ Q).
Applying the formal definition to our e-commerce order, we get:
Q = {PendingPayment, Processing, Shipped, Delivered, Cancelled}: Our possible states are the five distinct statuses of an order.Σ = {0, 1}: Representing the "failure/cancellation" and "success" signals, respectively.δis defined by our rules. For example:δ(PendingPayment, 1) = Processing: An order pending payment that receives a "success" signal (1) transitions to processing.δ(Shipped, 0) = Cancelled: A shipped order that receives a "failure" signal (0) transitions to cancelled.δ(Delivered, 1) = Delivered: A delivered order that receives any signal remains in the delivered state (a self-loop).
q₀ = PendingPayment: Every new order begins in thePendingPaymentstate.F = {Delivered, Cancelled}: An order is considered "finished" or complete when it reaches either theDeliveredorCancelledstate. These are visually represented in our diagram by a double circle.
From definition to React/TS: Creating an onboarding flow
TLDR: here is the code
So, we have a formal 5-tuple definition. How does this abstract concept help us write better React code?
By providing a precise blueprint, the formal structure forces us to think clearly about all possible states, valid user actions, and the transitions between them before we write a single line of code. Let’s see this in action.
Scaffolding
First of all, let’s create our project. For this tutorial, we are using Vite, so open your terminal and run the command: pnpm create vite. Choose Typescript + React and cd into the project’s folder. Now, install the dependencies and remove all unnecessary boilerplate code.
Modeling the Flow: The Abstraction
We will tackle the abstraction behind our onboarding flow first so we can design it independently of the view. Our machine will be able to transition back and forth between states and it will be deterministic. Here is the diagram for what we will build:
Image 4: Our onboarding flow DFSM diagram.

Here, I’m imagining a four step onboarding flow, where the user is supposed to provide personal information (PI), education (ED), and work experience (WE) data. There is also a summary step (SM), so the user can check everything they have filled out before submitting to the backend (thus exiting the flow).
Since we can transition back and forth, reading previous in the initial state PI would mean to leave the page where the onboarding flow is being rendered; similarly, reading next in SM would also mean to leave, since we have completed the flow.
Notice that Leave is not one of the onboarding flow’s states, but we are considering it as a valid result of a transition to better fit the formal definition into our real world application.
Controlling State and Data State
First of all, it’s important to clarify what "state" means in our React application. We are actually managing two related pieces of information: the Control State, which is the current step of the flow (e.g., PI, ED), and the Data State, which is the information the user has entered so far.
Our Deterministic Finite State Machine will be responsible for managing the Control State, ensuring we can only be in valid steps and perform valid transitions. But we need to manage Data State at the same time.
With that distinction in mind, let’s start by modeling our Control State. To define Q, create a file called OnboardingFlowStep.ts.
export type OnboardingFlowStep =
| "PersonalInfo"
| "Education"
| "WorkExperience"
| "Summary";
export const OnboardingFlowSteps = {
PersonalInfo: "PersonalInfo",
Education: "Education",
WorkExperience: "WorkExperience",
Summary: "Summary",
} as const satisfies Record<string, OnboardingFlowStep>;
export const OnboardingFlowInitialStep = OnboardingFlowSteps.PersonalInfo;Similarly, we define our initial state, q₀, as a constant: OnboardingFlowInitialStep. This clearly explains where we want our flow to start. We could have done the same for our accept state, for example: const OnboardingFlowAcceptStep = OnboardingFlowSteps.Summary;
A Powerful Pattern: Step-Specific Schemas
Now, we need types to represent the data that we will hold on each step. To model the Data State, we can create distinct files for each piece of information we are working with:
// PersonalInfo.ts
export type PersonalInfo = {
fullName: string;
location: string;
birthDate: Date;
};
// Education.ts
export type Education = {
school: string;
degree: EducationDegree;
fieldOfStudy: string;
graduationYear: number;
};
export type EducationDegree = "Undergraduate" | "Graduate" | "MSc" | "PhD";
// WorkExperience.ts
export type WorkExperience = {
company: string;
jobTitle: string;
yearsInPosition: number;
};Alright, now we have to bind everything. We need some pattern to represent the control of the onboarding flow at each step while holding the data.
A common solution, which is also an anti-pattern, is to do something like this:
export type OnboardingFlow = {
currentStep: OnboardingFlowStep;
personalInfo?: PersonalInfo;
education?: Education;
workExperience?: WorkExperience;
};With this approach, you lose the greatest benefits of using TypeScript and DFSMs: a type-safe approach, that gives you certainty on what data is actually available at any given step.
Now, let’s define our OnboardingFlow type using a powerful TypeScript pattern. We want that the shape of the data state object is dependent on the control state (current step). Check this:
...imports here...
export type OnboardingFlow =
| OnboardingFlowAtPersonalInfo
| OnboardingFlowAtEducation
| OnboardingFlowAtWorkExperience
| OnboardingFlowAtSummary;
export type OnboardingFlowAtPersonalInfo = {
step: (typeof OnboardingFlowSteps)["PersonalInfo"];
personalInfo: PersonalInfo | undefined;
};
export type OnboardingFlowAtEducation = {
step: (typeof OnboardingFlowSteps)["Education"];
personalInfo: PersonalInfo;
education: Education | undefined;
};
export type OnboardingFlowAtWorkExperience = {
step: (typeof OnboardingFlowSteps)["WorkExperience"];
personalInfo: PersonalInfo;
education: Education;
workExperience: WorkExperience | undefined;
};
export type OnboardingFlowAtSummary = {
step: (typeof OnboardingFlowSteps)["Summary"];
personalInfo: PersonalInfo;
education: Education;
workExperience: WorkExperience;
};Now, when onboardingFlow.step === "Education", for instance, TypeScript knows for a fact that onboardingFlow.personalInfo is defined. The same is true for the rest of the flow, eliminating entire classes of bugs.
Transitions and alphabet
Finally, we must finish the Control State with our transitions and alphabet. How can we do so? A OnboardingFlowStepsMap.ts is all we need!
...imports here...
type MapStep = {
previous: OnboardingFlowStep | null;
next: OnboardingFlowStep | null;
};
export const OnboardingFlowStepsMap: Record<OnboardingFlowStep, MapStep> = {
PersonalInfo: {
previous: null,
next: OnboardingFlowSteps.Education,
},
Education: {
previous: OnboardingFlowSteps.PersonalInfo,
next: OnboardingFlowSteps.WorkExperience,
},
WorkExperience: {
previous: OnboardingFlowSteps.Education,
next: OnboardingFlowSteps.Summary,
},
Summary: {
previous: OnboardingFlowSteps.WorkExperience,
next: null,
},
} as const;See how the map is the practical implementation of our transition function, δ? It takes a step (like PersonalInfo) and an input (next) and tells us the resulting state (Education).
So, our machine is now fully modeled. To bring it all together, let’s see how our TypeScript implementation maps directly back to the formal 5-tuple definition we learned:
Q is our
OnboardingFlowStepunion type;Σ is the set of actions,
{'previous', 'next'};δ is our
OnboardingFlowStepsMap, which defines the next state for each action;q₀ is
OnboardingFlowInitialStep;F is a set containing the
Summarystep."
Connecting to the UI: The Presentation Layer
I’m not pasting the whole code here. In this section, I’ll just explain how we can create a bridge between the flow’s abstraction and its presentation. Please refer to the code to check all details.
To make this connection, we will rely on React’s legendary trio: context, a provider, and a hook. Let’s build a context to deliver our flow:
...imports here...
type OnboardingFlowContextValue = {
onboardingFlow: OnboardingFlow;
changeOnboardingFlow: (obf: OnboardingFlow) => void;
};
export const OnboardingFlowContext = createContext<
OnboardingFlowContextValue | undefined
>(undefined);And a provider for it:
...imports here...
type OnboardingFlowProviderProps = {
initialValue: OnboardingFlow;
children: ReactNode;
};
export const OnboardingFlowProvider = ({
initialValue,
children,
}: OnboardingFlowProviderProps) => {
const [onboardingFlow, setOnboardingFlow] =
useState<OnboardingFlow>(initialValue);
const value = useMemo(() => {
const changeOnboardingFlow = (obf: OnboardingFlow) =>
setOnboardingFlow(obf);
return {
onboardingFlow,
changeOnboardingFlow,
};
}, [onboardingFlow]);
return (
<OnboardingFlowContext.Provider value={value}>
{children}
</OnboardingFlowContext.Provider>
);
};Lastly, useOnboardingFlow.ts is the piece we need to manipulate the onboarding flow:
...imports here...
export function useOnboardingFlow() {
const value = useContext(OnboardingFlowContext);
if (!value)
throw new Error(
"Ops! This should be used inside a OnboardingFlowProvider!",
);
const { onboardingFlow, changeOnboardingFlow } = value;
// Here we place all methods to make transitions
return {
onboardingFlow,
fillPersonalInfo,
fillEducation,
fillWorkExperience,
goBackInOnboardingFlow,
};
}Now wrap your App.tsx with it and voila! We’ve nailed our task!
...imports here...
function createOnboardingFlow(): OnboardingFlowAtPersonalInfo {
return {
step: OnboardingFlowInitialStep,
personalInfo: undefined,
};
}
export function App() {
return (
<OnboardingFlowProvider initialValue={createOnboardingFlow()}>
{/* Your custom components here! */}
</OnboardingFlowProvider>
);
}Conclusion
Here is a gif showing my implementation of the onboarding flow.
I hope that you’ve enjoyed this journey from abstract theory to a practical application. If you start approaching real-world problems in terms of states and transitions, you’ll see opportunities to use state machines in many different situations.
Just don’t forget to separate the logic from the presentation when coding; it will save you some time!
We want to work with you. Check out our Services page!

