State-based code design
How explicitly modeling states, events, and transitions can reduce ambiguity, prevent invalid combinations, and make code behavior clearer.
The idea of finite automata is interesting beyond theory because it points to a way of organizing programs. Not in the sense of turning every system into a formal diagram, nor of using some “state management” library. The issue comes before that. It is about thinking of a program’s behavior in terms of the states it can be in, the events it can receive, and the transitions those events produce.
This does not mean that every behavior needs to become a formal state machine. In many cases, ordinary conditionals are enough. The point is different: when a system already has states in practice, hiding them in flags, optional fields, and implicit combinations usually makes the model worse.
Many systems start without this clear distinction. The code grows from local cases: if the user is authenticated, do this; if the payment was approved, do that; if the order has not been shipped yet, allow cancellation; if the field is empty, block it; if the request failed, try again; if it has already tried three times, show an error. Each condition seems reasonable where it was written. The problem appears when these conditions begin to combine.
The system’s logic then stops being concentrated in a recognizable form and becomes scattered across if, else, flags, null values, booleans, callbacks, and exceptions. The program still has states, but they do not appear as states. They appear as implicit combinations of variables.
For example:
if (isLoading && !hasError && !data) {
...
}
if (!isLoading && hasError && retryCount < 3) {
...
}
if (!isLoading && !hasError && data && !isExpired) {
...
}
This kind of code seems to be merely checking conditions. But in practice, it is trying to describe a state machine without naming the machine. There is a loading state, an error state, a success state, perhaps an expired state, perhaps a retrying state. But these states have been broken into pieces and distributed among variables.
The result is that the system begins to accept combinations that may not even make sense:
isLoading = true
hasError = true
data = {...}
What does this represent? Is it loading, did it fail, and does it have valid data at the same time? Perhaps there is an explanation. Perhaps not. The point is that the code allowed an ambiguous situation. From that point on, each new function needs to defend itself against that ambiguity with more conditionals.
A state-based design tries to avoid this kind of situation from the foundation. Instead of representing behavior through a loose set of indicators, it starts by asking: what states can this system be in?
For example:
type RequestState =
| { type: "idle" }
| { type: "loading" }
| { type: "success"; data: Data }
| { type: "error"; reason: Error; retryCount: number }
| { type: "expired"; data: Data };
Here, the state is not an indirect consequence of several flags. It is an explicit shape. The system cannot be in loading and success at the same time unless that is modeled as its own state. There cannot be data in a state that should not have data. There cannot be an error without a reason. The structure of the code starts limiting the possible combinations.
This does not eliminate complexity. It only changes where that complexity lives. Instead of letting complexity be scattered across local checks, it is brought into the system model.
It is also important to separate different kinds of state. Sometimes we are talking about interface state, such as loading, success, or error. Sometimes we are talking about domain state, such as an order being paid, shipped, cancelled, or refunded. Sometimes we are talking about operational state, such as a retry attempt, timeout, ongoing processing, or pending queue item.
These categories can overlap, but they are not the same thing. A payment may be under_review in the domain, while the screen is in loading and an external integration is waiting for a retry. Mixing all of these levels into a single set of flags often makes the code more confusing than necessary. Modeling states explicitly does not mean putting everything into one enumeration. It means understanding which modes are relevant in each part of the system.
The same applies to transitions. A state-based system does not define only “what the data is like.” It also defines which events can move the system from one state to another.
type Event =
| { type: "FETCH" }
| { type: "RESOLVE"; data: Data }
| { type: "REJECT"; reason: Error }
| { type: "RETRY" }
| { type: "EXPIRE" }
| { type: "RESET" };
From there, the logic stops being a collection of scattered conditionals and becomes a transition function:
function transition(state: RequestState, event: Event): RequestState {
switch (state.type) {
case "idle":
if (event.type === "FETCH") {
return { type: "loading" };
}
return state;
case "loading":
if (event.type === "RESOLVE") {
return { type: "success", data: event.data };
}
if (event.type === "REJECT") {
return {
type: "error",
reason: event.reason,
retryCount: 0,
};
}
return state;
case "success":
if (event.type === "EXPIRE") {
return { type: "expired", data: state.data };
}
if (event.type === "RESET") {
return { type: "idle" };
}
return state;
case "error":
if (event.type === "RETRY" && state.retryCount < 3) {
return { type: "loading" };
}
if (event.type === "RESET") {
return { type: "idle" };
}
return state;
case "expired":
if (event.type === "FETCH") {
return { type: "loading" };
}
if (event.type === "RESET") {
return { type: "idle" };
}
return state;
}
}
The important detail is not the syntax. It could be another language, another style, another structure. The point is that the behavior is described in terms of current state, received event, and next state. This changes how the program is read.
In code based on loose conditionals, understanding behavior requires tracking variables. In code based on states, it becomes possible to ask directly: when in loading, what can happen? When in error, which events make sense? Is there any path from success to expired? Is it possible to return to idle?
These questions seem simple, but they are exactly the questions that many systems answer poorly when they grow without an explicit model.
There is also a gain in how edge cases appear. In poorly modeled code, edge cases emerge as unexpected combinations of variables. A button appears when it should not. An action is allowed too early. An event arrives twice. A delayed response overwrites more recent data. A screen tries to render data that does not exist yet. Each of these problems is often handled with a new condition.
if (!data) return;
if (isLoading) return;
if (hasError) return;
if (isSubmitting) return;
if (alreadySubmitted) return;
These defenses can work, but they rarely clarify the system. Often, they merely push the problem forward. The code becomes full of local refusals because there is no central definition of what is allowed in each situation.
When states are explicit, some of these cases stop being “edge cases” and become simply nonexistent transitions. If an event is not valid in a given state, it produces no change. Or it produces a controlled error. Or it is recorded as an unexpected event. The decision becomes conscious.
For example, a request response may arrive after the user has already restarted the flow. In a design based only on flags, this can cause an accidental overwrite. In a state-based design, that event may simply have no valid transition from the current state.
case "idle":
if (event.type === "RESOLVE") {
return state;
}
This is not a cosmetic detail. It is a difference in the model. The system does not need to improvise what to do with a delayed response. That possibility has already been considered as an event that can arrive in the wrong state.
The same reasoning applies to more complex flows: orders, payments, authentication, onboarding, content publishing, file processing, asynchronous pipelines, external integrations. All of these domains tend to suffer when implicit states accumulate.
An order, for example, might be in states such as:
type OrderState =
| { type: "draft" }
| { type: "awaiting_payment" }
| { type: "paid" }
| { type: "preparing" }
| { type: "shipped"; trackingCode: string }
| { type: "delivered" }
| { type: "cancelled"; reason: string }
| { type: "refunded"; refundId: string };
From this, certain rules become more natural. An order in draft can be edited. An order in awaiting_payment can be cancelled. An order in shipped perhaps can no longer be cancelled, but can be returned. A cancelled order should not be shipped. A refunded order should not receive a new charge.
Without explicit states, these rules tend to spread out:
if (
order.paymentStatus === "paid" &&
order.shippingStatus !== "shipped" &&
!order.cancelled &&
!order.refunded
) {
...
}
This kind of condition mixes dimensions that perhaps should be subordinate to a clearer flow state. It does not mean every system needs to have only one state axis. Sometimes there are parallel states: payment, delivery, inventory, anti-fraud, invoicing, support. Each of these axes can have its own cycle, its own transitions, and its own rules.
Forcing everything into a single OrderState can also be a mistake. An order can be paid and, at the same time, waiting for inventory picking. It can be approved by anti-fraud but still have no invoice. It can be shipped but have an open payment dispute. Trying to represent all of these combinations as a single list of states can produce an enumeration that is too large, hard to maintain, and not very faithful to the domain.
In these cases, the better model may be composed of more than one smaller machine, or of main states accompanied by well-defined substates. The point remains the same: it is better to acknowledge the existence of these axes than to let them emerge accidentally from loose fields.
A good state design does not require reducing reality to a naive enumeration. It requires separating the relevant modes of the system. Some state machines are simple. Others are composed. Some have parallel states. Others have substates. Some need to store context together with the state. The important thing is to avoid behavior depending on unnamed combinations.
Modeling states also helps code scalability. Not because states automatically make the system simpler, but because they make growth more localized. When a new behavior needs to be added, the question stops being “where do I put one more if?” and becomes “in which states is this event valid?”
This difference matters. Adding a rule to an implicit system tends to require scattered searches. You need to find every place where a certain combination can appear. In a state-based system, the new rule tends to fit into the existing map: perhaps it is a new event, a new transition, a new state, or a restriction on an existing transition.
For example, if a payment flow starts requiring manual review, this does not need to become a generic flag:
requiresManualReview = true
It can become a process state:
| { type: "under_review"; paymentId: string }
And then the rules remain associated with that state:
case "under_review":
if (event.type === "APPROVE") {
return { type: "paid", paymentId: state.paymentId };
}
if (event.type === "REJECT") {
return { type: "payment_rejected", reason: event.reason };
}
return state;
This makes the change more visible. The system now has a new step. That step has valid inputs. It has possible outputs. It has its own information. It is not merely a boolean exception cutting across the code.
Another gain appears in tests. State-based code is naturally testable because behavior can be verified as a transition. Given an initial state and an event, a next state is expected.
expect(
transition(
{ type: "awaiting_payment" },
{ type: "PAYMENT_CONFIRMED", paymentId: "pay_123" }
)
).toEqual({
type: "paid",
paymentId: "pay_123",
});
This kind of test is more direct than testing scattered side effects. It does not require assembling the whole system to verify a basic behavior rule. The transition becomes a clear unit of validation.
It also becomes easier to test invalid events:
expect(
transition(
{ type: "cancelled", reason: "user_request" },
{ type: "SHIP", trackingCode: "ABC" }
)
).toEqual({
type: "cancelled",
reason: "user_request",
});
Here, the test documents a rule: a cancelled order cannot be shipped. This rule is not hidden in a combination of fields. It appears as an impossible transition.
This also affects maintenance. When a system’s behavior is modeled by states, the code tends to become more resistant to changes because invariants stay close to the structure. A state can carry only the data that makes sense in it. A transition can validate only the events allowed at that point. A flow can be read without mentally reconstructing dozens of independent conditions.
There is a common kind of complexity that appears when code tries to represent different stages using the same objects, the same fields, and the same optional values. An object appears incomplete at the beginning, partially filled in the middle, valid at the end, invalid after a failure. To accommodate all of this, fields become optional.
type Upload = {
file?: File;
url?: string;
error?: Error;
progress?: number;
completedAt?: Date;
};
This type allows almost everything. It allows an upload without a file, an error with a final URL, progress after completion, completion without a date, file and error at the same time. Again, perhaps some combinations make sense. Many probably do not. The type does not help distinguish them.
A state-based model constrains this better:
type UploadState =
| { type: "empty" }
| { type: "selected"; file: File }
| { type: "uploading"; file: File; progress: number }
| { type: "uploaded"; url: string; completedAt: Date }
| { type: "failed"; file: File; error: Error };
Now each situation carries the data that belongs to it. The uploaded state does not need to have error. The failed state does not need to pretend it has url. The empty state does not need null fields. The code consuming this state does not need to defend itself against so many artificial combinations.
This reduces edge cases because many of them stop being representable. This may be one of the most important advantages: a good model does not merely handle errors; it prevents certain invalid forms from existing.
Of course, not every problem requires a formal state machine. There are simple cases where ordinary conditionals are enough. The mistake would be turning “state” into ceremony applied to every trivial function. A clear if, in a simple and local piece of code, is not a problem by itself.
The problem appears when behavior depends on order, flow, waiting, authorization, steps, retries, cancellations, concurrency, or external integration. Systems with these characteristics almost always benefit from more explicit modeling, because they already have states even when the code does not name them.
Some signs that a system is asking for this kind of modeling:
- many boolean flags controlling behavior;
- combinations that are hard to explain;
- too many optional fields;
- repeated validations in several places;
- bugs caused by out-of-order actions;
- asynchronous events arriving late;
- screens or processes with poorly defined intermediate steps;
- rules that depend on “has X already happened?”;
- functions that begin with several guard clauses;
- difficulty answering which situations the system supports.
In these cases, the problem is rarely just a lack of code organization. Often, what is missing is a more honest description of the system’s modes.
A state-based design starts by naming these modes. Then it defines the relevant events. Then it describes the transitions. Only after that do external effects enter: API calls, database writes, message emissions, interface updates. The order matters because side effects are easier to control when the core behavior has already been defined.
A common separation is this:
type State = ...
type Event = ...
type Effect = ...
function transition(state: State, event: Event): {
state: State;
effects: Effect[];
} {
...
}
The transition decides the next state and which effects should happen. Another part of the system executes those effects.
type Effect =
| { type: "send_email"; template: string; to: string }
| { type: "charge_card"; orderId: string }
| { type: "schedule_retry"; delayMs: number }
| { type: "log"; message: string };
This avoids mixing decision with execution. The transition function remains understandable and testable. The effects still exist, but they are not hidden inside arbitrary branches.
This separation also helps avoid confusing domain state with the effects needed to move the system forward. The fact that a transition emits charge_card does not mean the charge has already happened. It means that, from that state and that event, the system decided this effect should be executed. After that, another event may confirm the charge succeeded or failed.
This distinction is important in real systems because many external effects are fallible, slow, or duplicateable. An API may respond late. A message may be processed twice. A payment may remain pending. If these possibilities do not appear in the model, they appear later as scattered exceptions.
This design also helps when the system grows. New states can be added. New events can be handled. New effects can be emitted. Behavior changes inside a structure that already has a place for change. It is not necessary to turn every exception into a patch.
The relationship with finite automata appears here in a practical way. The basic model is the same: a set of states, a set of inputs, a transition function, and, when necessary, some output. But in real code, states can carry data, transitions can emit effects, and the system can be composed of several smaller machines. The idea does not need to remain at a purely formal level to be useful.
The important thing is that the program stops being seen as a set of checks over loose values and starts being seen as a set of possible situations. This changes how the foundation of the system is designed.
Instead of starting with:
if this
else that
but if also that other thing
except when such-and-such flag is active
one starts with:
what states exist?
what events arrive?
which transitions are valid?
what data belongs to each state?
what effects come out of each transition?
It is not a matter of style. It is a matter of reducing operational ambiguity.
A system with poorly defined states still works, but it works accidentally in many places. It depends on local discipline, comments, team memory, and tests covering combinations that the model unintentionally allowed. A system with explicit states places part of that discipline in the code structure itself.
This does not make the software immune to error. It only reduces the space in which certain errors can appear. If a state does not contain invalid data, the code does not need to check that data all the time. If a transition does not exist, an out-of-order action does not need to be patched afterward. If an intermediate step has been named, it can be tested, displayed, logged, observed, and modified.
The consequence is a more readable and more scalable system in the most ordinary sense of the word: it supports growth without depending so much on guesswork. New behaviors need to find their place in the set of states and transitions. When they do not, that is already useful information. Perhaps the model is incomplete. Perhaps the new requirement introduces a state that was previously implicit. Perhaps two situations that seemed identical need to be separated. Perhaps two machines that were mixed together need to be treated as different axes.
This is where the simple theory of automata meets a practical approach to code design. The question is not whether the system is formally a finite automaton. The question is whether its behavior is modeled or merely scattered.
Well-defined states are not meant to make code more elegant. They are meant to make the system’s operation explicit: what it does, under what conditions it does it, what it accepts, what it rejects, what changes, and what must remain impossible.