How to implement state machine with enums

Implement a state machine in Rust by defining an enum for states and using match expressions to handle transitions.

The dial that can't break

You are building a network client. It needs to track whether it is trying to connect, fully connected, or disconnected. In Python, you might use a string variable like status = "connecting". In JavaScript, you might use a number 0 for idle and 1 for active. These approaches work until you typo "conecting" or pass -1 by accident. The runtime accepts the value, and your logic falls apart hours later.

Rust gives you a better tool. You define an enum that lists every valid state. The compiler guarantees that the value can only be one of those states. You cannot typo a state. You cannot pass an invalid number. The type system acts as a physical dial with distinct positions. You can be on "AM", "FM", or "OFF". You cannot be on "AM-FM" unless the hardware supports it.

Enums as state containers

A state machine is just a value that changes over time based on events. Rust enums are perfect for this because they define a closed set of possibilities. Each variant represents a state. You can attach data to variants, so a Connecting state can hold a retry count while a Connected state holds a session token.

The compiler enforces exhaustiveness. When you write a match to handle transitions, you must cover every variant. If you add a new state like Error, the compiler rejects your code until you handle the transition. This catches logic holes at compile time, not in production.

Minimal example: unit variants

Start with states that carry no data. This is common for simple flags or when the context lives elsewhere.

/// Represents the lifecycle of a connection.
#[derive(Debug, Clone, Copy)]
enum ConnectionState {
    /// The client is not connected.
    Disconnected,
    /// The client is attempting to connect.
    Connecting,
    /// The client is actively connected.
    Connected,
}

impl ConnectionState {
    /// Computes the next state based on an event string.
    /// Returns the new state without mutating self.
    fn transition(&self, event: &str) -> Self {
        match self {
            // Disconnected moves to Connecting on a connect event.
            ConnectionState::Disconnected if event == "connect" => ConnectionState::Connecting,
            // Connecting moves to Connected on success.
            ConnectionState::Connecting if event == "success" => ConnectionState::Connected,
            // Connected moves to Disconnected on disconnect.
            ConnectionState::Connected if event == "disconnect" => ConnectionState::Disconnected,
            // Default: stay in the current state if the event is irrelevant.
            _ => *self,
        }
    }
}

fn main() {
    let mut state = ConnectionState::Disconnected;
    
    // Drive the machine with events.
    state = state.transition("connect");
    println!("{:?}", state); // Connecting
    
    state = state.transition("success");
    println!("{:?}", state); // Connected
}

The #[derive(Clone, Copy)] attribute makes the enum cheap to duplicate. Unit variant enums fit in a single byte. Copying them is as fast as copying an integer. The transition method takes &self because it only reads the current state. It returns a new Self because Rust prefers returning values over mutating in place. This keeps the logic pure and avoids aliasing issues.

Return the new state. Don't try to mutate in place; the compiler will block you, and returning a value keeps the logic pure.

Realistic example: states with data

Real state machines often need to carry context. A Connecting state might track how many times it has retried. A Connected state might hold a session ID. When states carry data, the transition logic changes. You take ownership of the state to move data between variants without cloning.

/// A client state machine that tracks connection attempts and session data.
#[derive(Debug)]
enum ClientState {
    /// Disconnected state. Tracks failed attempts for backoff logic.
    Disconnected { attempts: u32 },
    /// Connecting state. Holds the target host being contacted.
    Connecting { host: String },
    /// Connected state. Holds the active session token.
    Connected { token: String },
}

impl ClientState {
    /// Transitions the state based on an event.
    /// Takes self by value to allow moving data between states.
    fn step(self, event: &str) -> Self {
        match self {
            // Move out of Disconnected.
            ClientState::Disconnected { attempts } => {
                if event == "connect" {
                    // Transition to Connecting with a new host.
                    ClientState::Connecting { host: "api.example.com".to_string() }
                } else if event == "retry" {
                    // Stay disconnected but increment attempts.
                    ClientState::Disconnected { attempts: attempts + 1 }
                } else {
                    // No valid event; return self.
                    ClientState::Disconnected { attempts }
                }
            }
            // Move out of Connecting.
            ClientState::Connecting { host } => {
                if event == "success" {
                    // Transition to Connected, generating a token from the host.
                    ClientState::Connected { token: format!("token-for-{}", host) }
                } else if event == "fail" {
                    // Transition to Disconnected, resetting attempts.
                    ClientState::Disconnected { attempts: 0 }
                } else {
                    // Stay connecting.
                    ClientState::Connecting { host }
                }
            }
            // Move out of Connected.
            ClientState::Connected { token } => {
                if event == "disconnect" {
                    // Transition to Disconnected.
                    ClientState::Disconnected { attempts: 0 }
                } else {
                    // Stay connected.
                    ClientState::Connected { token }
                }
            }
        }
    }
}

fn main() {
    let mut state = ClientState::Disconnected { attempts: 0 };
    
    state = state.step("connect");
    println!("{:?}", state); // Connecting { host: "api.example.com" }
    
    state = state.step("success");
    println!("{:?}", state); // Connected { token: "token-for-api.example.com" }
}

The step method takes self by value, not &self. This allows the match arms to destructure the current state and move fields out. If Connecting holds a String, you can move that string into the next state or drop it. Taking &self would force you to clone the string, which allocates memory unnecessarily. Taking ownership is the idiomatic pattern for state transitions that involve data movement.

Take ownership of the state when it carries data. Moving values between states is cheaper than cloning, and it prevents accidental data leaks.

Guards and conditions

State transitions often depend on conditions beyond the event name. Maybe you only retry if attempts are below a limit. Maybe you only disconnect if a timeout occurs. Rust match arms support guards, which are boolean expressions that must be true for the arm to match.

impl ClientState {
    fn step_with_guards(self, event: &str) -> Self {
        match self {
            // Only retry if attempts are less than 3.
            ClientState::Disconnected { attempts } 
                if event == "retry" && attempts < 3 => 
            {
                ClientState::Connecting { host: "api.example.com".to_string() }
            }
            // If attempts exceed the limit, stay disconnected.
            ClientState::Disconnected { attempts } if event == "retry" => {
                ClientState::Disconnected { attempts }
            }
            // Other transitions...
            _ => self,
        }
    }
}

Guards let you express complex logic without nesting if statements inside the match. The compiler still checks exhaustiveness. If a guard fails, the match continues to the next arm. You must handle the case where the guard is false, either with another arm or a wildcard.

Convention aside: Order your match arms by the variant they match, then by guard specificity. Put the most specific guards first. This makes the code easier to read and reduces the chance of a broad guard swallowing a case it shouldn't.

Pitfalls and compiler errors

State machines in Rust are robust, but a few patterns trip up beginners.

Non-exhaustive patterns. If you add a new variant to the enum but forget to handle it in the match, the compiler rejects the code with E0004 (non-exhaustive patterns in match). The error lists the missing variant. This is a feature, not a bug. It forces you to update all transition logic when the state space changes. Fix the code by adding the missing arm.

Moving out of borrowed content. If your method takes &self but you try to move a field out of the state, you get E0507 (cannot move out of borrowed content). For example, trying to extract a String from &ClientState::Connecting { host } fails because the borrow doesn't allow moving the string. The fix is to take self by value, as shown in the realistic example.

Mismatched types. If a match arm returns a different type than the others, you get E0308 (mismatched types). This happens if one arm returns Self and another returns (). Ensure every arm returns the same type. Use self or a reconstructed variant in the default case.

Infinite recursion. If a transition calls the transition method recursively without changing the state, you will stack overflow. Ensure every event either changes the state or returns immediately.

Treat the exhaustiveness check as your safety net. If the compiler flags a missing pattern, you have a bug waiting to happen. Add the arm.

Decision: when to use enums vs alternatives

Use enums for state machines when the set of states is closed and known at compile time. Use enums when states carry different data payloads, like a Connecting state holding a retry count while Connected holds a session ID. Use a struct with a state field when you need to mutate the state in place without returning a new value, though this often leads to partial states and harder reasoning. Use a dedicated state machine library when your machine has complex guards, events, or history requirements that make manual match logic unwieldy.

Start with enums. They are built-in, zero-cost, and the compiler enforces correctness. Reach for libraries only when the complexity of guards and history outweighs the simplicity of a match.

Where to go next