The State Pattern in C++: State Objects, Safe Transitions and std::variant
Key takeaways
The State pattern moves each mode of an object into its own class so behavior and transitions live together. This post builds a traffic light and an enemy AI with it, explains the use-after-free that naive transitions cause, and shows when a std::variant state machine is the better C++ choice.
The problem: behavior that depends on a mode
Many objects behave differently depending on what mode they are in. A connection can be closed, listening or established; a vending machine can be waiting for a coin, holding credit or dispensing. The straightforward implementation stores an enum and checks it in every method:
class TCPConnection {
enum State { CLOSED, LISTEN, ESTABLISHED };
State state = CLOSED;
public:
void open() {
if (state == CLOSED) { state = LISTEN; }
else if (state == LISTEN) { /* already open */ }
else if (state == ESTABLISHED) { /* error */ }
}
void close() {
if (state == CLOSED) { /* error */ }
else if (state == LISTEN) { state = CLOSED; }
else if (state == ESTABLISHED) { state = CLOSED; }
}
};
With three states and two operations this is fine. The trouble grows multiplicatively: every new state adds a branch to every method, and every new method needs a branch for every state. The knowledge “what does ESTABLISHED do?” ends up spread across the whole class, and adding a state means editing every method and hoping you did not miss one. Compilers can warn about a missing enumerator in a switch (-Wswitch, enabled by -Wall), but not in an if/else chain like this one.
The State pattern turns the table sideways. Each state becomes a class that implements all operations for that one mode, and the object (the context) holds a pointer to its current state and forwards calls to it. Adding a state means adding one class; the knowledge about a mode lives in one place.
stateDiagram-v2
[*] --> Closed
Closed --> Listen: open()
Listen --> Established: acknowledge()
Established --> Closed: close()
Listen --> Closed: close()
This diagram is a teaching simplification. The real TCP state machine in RFC 793 has eleven states (SYN-SENT, FIN-WAIT-1, TIME-WAIT and so on), which is itself a good illustration of why the pattern exists.
Basic structure: a traffic light
#include <iostream>
#include <memory>
#include <string>
class TrafficLight;
class State {
public:
virtual ~State() = default;
virtual void handle(TrafficLight& light) = 0;
virtual std::string name() const = 0;
};
class TrafficLight {
public:
TrafficLight();
void setState(std::unique_ptr<State> s) {
state_ = std::move(s);
std::cout << "State: " << state_->name() << '\n';
}
void change() { state_->handle(*this); }
private:
std::unique_ptr<State> state_;
};
class RedState : public State {
public:
void handle(TrafficLight& light) override;
std::string name() const override { return "Red"; }
};
class GreenState : public State {
public:
void handle(TrafficLight& light) override;
std::string name() const override { return "Green"; }
};
class YellowState : public State {
public:
void handle(TrafficLight& light) override;
std::string name() const override { return "Yellow"; }
};
void RedState::handle(TrafficLight& light) { light.setState(std::make_unique<GreenState>()); }
void GreenState::handle(TrafficLight& light) { light.setState(std::make_unique<YellowState>()); }
void YellowState::handle(TrafficLight& light) { light.setState(std::make_unique<RedState>()); }
TrafficLight::TrafficLight() : state_(std::make_unique<RedState>()) {
std::cout << "Initial state: " << state_->name() << '\n';
}
int main() {
TrafficLight light;
light.change(); // Red -> Green
light.change(); // Green -> Yellow
light.change(); // Yellow -> Red
}
Output:
Initial state: Red
State: Green
State: Yellow
State: Red
Two C++-specific details make this compile. States and context refer to each other, so the context is forward-declared (class TrafficLight;) and the state methods that call setState are defined after all classes are complete. If you try to write std::make_unique<GreenState>() inside RedState’s class body before GreenState exists, GCC stops with error: 'GreenState' was not declared in this scope. Splitting declarations and definitions is the standard fix, and in real code it falls out naturally from putting each state in its own .cpp file.
The transition trap: a state that deletes itself
Look closely at what happens during light.change():
change()callsstate_->handle(*this)— we are now executing a member function of theRedStateobject.handlecallslight.setState(...), which assigns a newunique_ptrtostate_.- That assignment destroys the
RedStateobject whose member function is still running.
The example above is legal only because handle returns immediately after setState and touches nothing else. Add one innocent line after the transition — logging a member, incrementing a counter in the state, calling name() — and you are reading freed memory. Nothing warns at compile time, and in a debug build it often appears to work because the freed memory has not been reused yet.
This is the bug I would look for first when reviewing any State pattern implementation. It tends to appear months after the code was written, when someone adds an “on exit” log line at the end of a handle function, and it shows up as garbage in log output or a crash in an unrelated allocation. AddressSanitizer reports it as heap-use-after-free at the exact line, which is by far the quickest way to confirm it.
The robust fix is to not let states replace themselves. Instead, a state returns the next state, and the context performs the swap after the state’s code has finished:
class AIState {
public:
virtual ~AIState() = default;
// Returns the next state, or nullptr to stay in this one.
virtual std::unique_ptr<AIState> update(Enemy& enemy) = 0;
virtual std::string name() const = 0;
};
void Enemy::update() {
std::cout << "[" << state_->name() << "]\n";
if (auto next = state_->update(*this)) { // current state finishes first
std::cout << " -> " << next->name() << '\n';
state_ = std::move(next); // then it is replaced
}
}
This has benefits beyond memory safety. Every transition now passes through one function, which is the natural place for logging, validating that a transition is allowed, recording history, or calling onExit()/onEnter() hooks in a predictable order.
Game AI example
An enemy that patrols, chases, attacks and flees is the classic use case: each state has different per-frame behavior and different conditions for leaving.
#include <iostream>
#include <memory>
#include <string>
class Enemy;
class AIState {
public:
virtual ~AIState() = default;
virtual std::unique_ptr<AIState> update(Enemy& enemy) = 0;
virtual std::string name() const = 0;
};
class Enemy {
public:
Enemy(int hp, int dist);
void update();
int health() const { return health_; }
int distance() const { return distance_; }
void takeDamage(int dmg) { health_ -= dmg; }
void setDistance(int d) { distance_ = d; }
private:
std::unique_ptr<AIState> state_;
int health_;
int distance_;
};
class PatrolState : public AIState {
public:
std::unique_ptr<AIState> update(Enemy& e) override;
std::string name() const override { return "Patrol"; }
};
class ChaseState : public AIState {
public:
std::unique_ptr<AIState> update(Enemy& e) override;
std::string name() const override { return "Chase"; }
};
class AttackState : public AIState {
public:
std::unique_ptr<AIState> update(Enemy& e) override;
std::string name() const override { return "Attack"; }
};
class FleeState : public AIState {
public:
std::unique_ptr<AIState> update(Enemy& e) override;
std::string name() const override { return "Flee"; }
};
std::unique_ptr<AIState> PatrolState::update(Enemy& e) {
std::cout << " patrolling\n";
if (e.distance() < 10) return std::make_unique<ChaseState>();
return nullptr;
}
std::unique_ptr<AIState> ChaseState::update(Enemy& e) {
std::cout << " chasing\n";
if (e.health() < 20) return std::make_unique<FleeState>();
if (e.distance() > 20) return std::make_unique<PatrolState>();
if (e.distance() < 3) return std::make_unique<AttackState>();
return nullptr;
}
std::unique_ptr<AIState> AttackState::update(Enemy& e) {
std::cout << " attacking\n";
if (e.health() < 20) return std::make_unique<FleeState>();
if (e.distance() > 5) return std::make_unique<ChaseState>();
return nullptr;
}
std::unique_ptr<AIState> FleeState::update(Enemy& e) {
std::cout << " fleeing\n";
if (e.distance() > 30) return std::make_unique<PatrolState>();
return nullptr;
}
// Defined after PatrolState is complete, so make_unique can use it.
Enemy::Enemy(int hp, int dist)
: state_(std::make_unique<PatrolState>()), health_(hp), distance_(dist) {}
void Enemy::update() {
std::cout << "[" << state_->name() << "]\n";
if (auto next = state_->update(*this)) {
std::cout << " -> " << next->name() << '\n';
state_ = std::move(next);
}
}
int main() {
Enemy enemy(100, 15);
enemy.update(); // far away: keep patrolling
enemy.setDistance(5);
enemy.update(); // close: start chasing
enemy.setDistance(2);
enemy.update(); // very close: attack
enemy.takeDamage(85);
enemy.update(); // low health: flee
}
Output:
[Patrol]
patrolling
[Patrol]
patrolling
-> Chase
[Chase]
chasing
-> Attack
[Attack]
attacking
-> Flee
Notice that each update runs the current state’s behavior and then decides where to go, so a transition takes effect on the next frame. That one-frame delay is usually what you want in a game loop, but it is a design decision: if a state must act on the frame it is entered, the context has to call the new state immediately after the swap, and you need a guard against states bouncing back and forth forever within one frame.
The order of checks inside a state is also behavior. ChaseState tests health before distance, so a badly hurt enemy flees even when the player is in attack range. With a flat switch, those priorities tend to be scattered; with one class per state they are in one short function you can read top to bottom.
Allocation per transition, and sharing stateless states
Every transition above allocates a new state object and frees the old one. For a few objects that is irrelevant. For thousands of entities updated every frame, it is avoidable work — and it is only necessary when states carry per-object data.
If a state has no data members, one instance can be shared by every context. The context then stores a non-owning pointer instead of a unique_ptr:
class State {
public:
virtual ~State() = default;
virtual const State& next() const = 0;
virtual const char* name() const = 0;
};
class Red : public State { public: const State& next() const override; const char* name() const override { return "Red"; } };
class Green : public State { public: const State& next() const override; const char* name() const override { return "Green"; } };
class Yellow : public State { public: const State& next() const override; const char* name() const override { return "Yellow"; } };
inline const Red kRed; // one shared instance each (C++17 inline variables)
inline const Green kGreen;
inline const Yellow kYellow;
const State& Red::next() const { return kGreen; }
const State& Green::next() const { return kYellow; }
const State& Yellow::next() const { return kRed; }
class Light {
const State* state_ = &kRed; // not owned
public:
void change() { state_ = &state_->next(); std::cout << state_->name() << '\n'; }
};
This is the Flyweight idea applied to states, and it also removes the self-deletion problem entirely, since nothing is destroyed on transition. The price is that shared states must genuinely be stateless: the moment someone adds a “time entered” field to Red, every traffic light in the program shares one clock. Per-object data then belongs in the context, and the state methods read it from the context reference they receive.
Recording transition history
Once transitions go through one function, history is a few lines. Just remember that the context may not have a state yet the first time:
void Context::transitionTo(std::unique_ptr<State> next) {
if (state_) history_.push_back(state_->name());
state_ = std::move(next);
}
A bounded history (the last N transitions, kept in a ring buffer) is one of the most useful debugging aids for state machines, because “how did it get into this state?” is the question every bug report about one eventually becomes.
A compile-time checked alternative: std::variant
The classic pattern uses inheritance, but C++17 offers another way to express “exactly one of these states”: std::variant. Each state is a plain struct holding only its own data, and the transition function is a visit over the pair (state, event):
#include <iostream>
#include <variant>
struct NoCoin {};
struct HasCoin { int credit; };
struct Dispensing { int product; };
using VMState = std::variant<NoCoin, HasCoin, Dispensing>;
struct CoinInserted { int amount; };
struct ProductSelected { int product; };
struct Dispensed {};
using Event = std::variant<CoinInserted, ProductSelected, Dispensed>;
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
VMState next(const VMState& s, const Event& e) {
return std::visit(overloaded{
[](NoCoin, CoinInserted c) -> VMState { return HasCoin{c.amount}; },
[](HasCoin h, CoinInserted c) -> VMState { return HasCoin{h.credit + c.amount}; },
[](HasCoin h, ProductSelected p) -> VMState {
if (h.credit >= 100) return Dispensing{p.product};
std::cout << " not enough credit\n";
return h;
},
[](Dispensing, Dispensed) -> VMState { return NoCoin{}; },
[](const auto& state, const auto&) -> VMState { // every other combination
std::cout << " event ignored in this state\n";
return state;
},
}, s, e);
}
const char* name(const VMState& s) {
constexpr const char* names[] = {"NoCoin", "HasCoin", "Dispensing"};
return names[s.index()];
}
int main() {
VMState s = NoCoin{};
Event events[] = {ProductSelected{1}, CoinInserted{50}, ProductSelected{1},
CoinInserted{50}, ProductSelected{1}, Dispensed{}};
for (const Event& e : events) {
s = next(s, e);
std::cout << name(s) << '\n';
}
}
Output:
event ignored in this state
NoCoin
HasCoin
not enough credit
HasCoin
HasCoin
Dispensing
NoCoin
What you gain:
- No heap allocation. The variant stores the largest state inline; a transition is an assignment.
- Per-state data is typed.
creditonly exists while inHasCoin, so you cannot read a stale credit value while dispensing. - Exhaustiveness. Delete the catch-all lambda and the program no longer compiles. GCC’s error is long, but it names the missing case — here
const NoCoin&, const ProductSelected&— so the compiler lists exactly which (state, event) pair you forgot to handle. - No self-deletion.
nextis a pure function; it returns a new state and the caller assigns it.
What you give up is open extension. With the class hierarchy, adding a state means adding a class, and existing states do not change. With the variant, the list of states is closed and adding one means editing the using line and the visitor. For a machine that you own completely (protocol handlers, UI modes, parsers), that trade is usually worth it. For a plugin-style system where other modules contribute states, the virtual-function version fits better.
The catch-all lambda deserves a warning of its own. It is convenient, but it also silences the exhaustiveness check: add a new event type later, and every state silently ignores it instead of producing a compile error. In machines where ignoring an event is a real bug, I prefer to list the ignored combinations explicitly, or make the fallback log loudly so the gap shows up in testing.
Choosing an implementation
| Approach | Good fit | Watch out for |
|---|---|---|
enum class + switch | Few states, little per-state behavior | Logic for one state spread across methods |
State classes, owned by unique_ptr | States with their own data and several operations | Allocation per transition, self-deletion during handle |
| Shared stateless state objects | Many contexts, states with no data | Someone later adds a data member to a shared state |
std::variant + std::visit | Closed set of states, want compile-time exhaustiveness | Catch-all overloads hiding new events; long template errors |
FAQ
Q: How is this different from the Strategy pattern?
Strategy lets a client choose an algorithm, and the choice usually stays put. In State, the transitions are part of the behavior: states decide when the object moves to another state, and which operations are meaningful changes with the mode.
Q: How do asynchronous events fit in?
Don’t let multiple threads call into the state machine directly; the state and its transitions are shared mutable data. The common design is an event queue: producers push events, and a single consumer thread pops them and feeds them to the machine one at a time, so transitions stay sequential and easy to reason about.
Q: Where can I read more?
- Design Patterns by Gamma, Helm, Johnson and Vlissides (the original State pattern)
- Game Programming Patterns by Robert Nystrom, chapter “State”
- cppreference: std::variant and std::visit